mirror: A fast canonicalisation utility for stable object equality
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: canonicalizes more types

Marais Rossouw (Jun 24, 2026, 2:15 PM +1000) 05ad663a c5df2261

+451 -206
+411 -183
lib/mod.test.ts
··· 1 - import { 2 - assert, 3 - assertEquals, 4 - assertInstanceOf, 5 - assertNotEquals, 6 - assertNotMatch, 7 - assertThrows, 8 - } from '@std/assert'; 1 + import { assert, assertEquals, assertNotEquals, assertNotMatch, assertThrows } from '@std/assert'; 9 2 import fc from 'fast-check'; 10 3 import { identify } from '../lib/mod.ts'; 11 4 12 - Deno.test('exports', () => { 13 - assertInstanceOf(identify, Function); 5 + Deno.test('exports :: identify is a function', () => { 6 + assertEquals(typeof identify, 'function'); 7 + }); 8 + 9 + // ~> Primitives 10 + 11 + Deno.test('primitives :: are deterministic', () => { 12 + const stable = (v: unknown) => assertEquals(identify(v), identify(v), `${String(v)} is unstable`); 13 + stable('hello'); 14 + stable(''); 15 + stable(0); 16 + stable(-0); 17 + stable(123); 18 + stable(-4.5); 19 + stable(NaN); 20 + stable(Infinity); 21 + stable(-Infinity); 22 + stable(true); 23 + stable(false); 24 + stable(null); 25 + stable(undefined); 26 + stable(123n); 27 + }); 28 + 29 + Deno.test('primitives :: distinct values are distinct', () => { 30 + assertNotEquals(identify(1), identify(2)); 31 + assertNotEquals(identify('a'), identify('b')); 32 + assertNotEquals(identify(true), identify(false)); 33 + assertNotEquals(identify(1.5), identify(1.50001)); 34 + assertNotEquals(identify(''), identify(' ')); 35 + }); 36 + 37 + Deno.test('primitives :: are distinct from their string form', () => { 38 + // A number, boolean, or null is its own thing, never its text spelling. 39 + assertNotEquals(identify(1), identify('1')); 40 + assertEquals(identify('1'), identify('1')); 41 + assertNotEquals(identify(true), identify('true')); 42 + assertNotEquals(identify(null), identify('null')); 43 + assertNotEquals(identify(undefined), identify('undefined')); 44 + }); 45 + 46 + Deno.test('strings :: preserve exact content', () => { 47 + assertEquals(identify('café'), identify('café')); 48 + assertEquals(identify('a\n\t"b'), identify('a\n\t"b')); 49 + assertNotEquals(identify('a'), identify('A')); 50 + assertNotEquals(identify('ab'), identify('a b')); 51 + // A unicode round-trip over a wide range stays deterministic. 52 + for (let i = 0; i < 0x2000; i += 7) { 53 + const s = String.fromCharCode(i); 54 + assertEquals(identify(s), identify(s)); 55 + } 56 + }); 57 + 58 + Deno.test('numbers :: float precision is retained', () => { 59 + assertEquals(identify(0.1 + 0.2), identify(0.30000000000000004)); 60 + assertNotEquals(identify(0.1 + 0.2), identify(0.3)); 61 + assertNotEquals(identify(1), identify(1.0000000000000002)); 62 + }); 63 + 64 + Deno.test('numbers :: signed zero collapses', () => { 65 + // -0 and 0 are conventionally the same value. 66 + assertEquals(identify(-0), identify(0)); 67 + }); 68 + 69 + Deno.test('numbers :: non-finite values fold into null', () => { 70 + assertEquals(identify(NaN), identify(null)); 71 + assertEquals(identify(Infinity), identify(null)); 72 + assertEquals(identify(-Infinity), identify(null)); 73 + }); 74 + 75 + Deno.test('bigint :: matches the equivalent number', () => { 76 + assertEquals(identify(1n), identify(1)); 77 + assertEquals(identify(42n), identify(42)); 78 + assertEquals(identify(0n), identify(0)); 79 + }); 80 + 81 + Deno.test('symbols :: throw (cannot be coerced)', () => { 82 + assertThrows(() => identify(Symbol())); 83 + assertThrows(() => identify(Symbol('x'))); 84 + }); 85 + 86 + // ~> Dates & RegExps 87 + 88 + Deno.test('dates :: equal instants match, different instants differ', () => { 89 + assertEquals(identify(new Date(0)), identify(new Date(0))); 90 + assertEquals(identify(new Date(1700000000000)), identify(new Date(1700000000000))); 91 + assertNotEquals(identify(new Date(0)), identify(new Date(1))); 92 + }); 93 + 94 + Deno.test('dates :: are distinct from their numeric timestamp', () => { 95 + assertNotEquals(identify(new Date(0)), identify(0)); 14 96 }); 15 97 16 - Deno.test('arrays :: flat', () => { 98 + Deno.test('regexps :: source and flags both matter', () => { 99 + assertEquals(identify(/abc/gi), identify(/abc/gi)); 100 + assertNotEquals(identify(/a/), identify(/b/)); 101 + assertNotEquals(identify(/a/), identify(/a/g)); 102 + assertNotEquals(identify(/a/gi), identify(/a/g)); 103 + }); 104 + 105 + // ~> Arrays 106 + 107 + Deno.test('arrays :: flat equality', () => { 17 108 assertEquals(identify([1, 2, 3]), identify([1, 2, 3])); 18 109 }); 19 110 111 + Deno.test('arrays :: empty', () => { 112 + assertEquals(identify([]), identify([])); 113 + assertNotEquals(identify([]), identify([0])); 114 + }); 115 + 20 116 Deno.test('arrays :: order is significant', () => { 21 117 assertNotEquals(identify([3, 2, 1]), identify([1, 2, 3])); 22 118 }); 23 119 24 120 Deno.test('arrays :: nested', () => { 25 121 assertEquals( 26 - identify([ 27 - [3, 2, 1], 28 - [1, 2, 3], 29 - ]), 30 - identify([ 31 - [3, 2, 1], 32 - [1, 2, 3], 33 - ]), 122 + identify([[3, 2, 1], [1, 2, 3]]), 123 + identify([[3, 2, 1], [1, 2, 3]]), 124 + ); 125 + assertNotEquals( 126 + identify([[1], [2, 3]]), 127 + identify([[1, 2], [3]]), 34 128 ); 35 129 }); 36 130 37 131 Deno.test('arrays :: circular', () => { 38 - const arr = [1, 2, 3]; 39 - // @ts-expect-error circular 132 + const arr: unknown[] = [1, 2, 3]; 40 133 arr.push(arr); 41 134 assert(identify(arr)); 42 - assertEquals(identify(arr), 'a123~1'); 43 135 assertEquals(identify(arr), identify(arr)); 44 136 }); 45 137 46 - Deno.test('objects :: basic', () => { 138 + Deno.test('arrays :: undefined elements fold into null', () => { 139 + assertEquals(identify([1, undefined, 2]), identify([1, null, 2])); 140 + }); 141 + 142 + Deno.test('arrays :: adjacent elements keep their own slots', () => { 143 + assertNotEquals(identify(['a', 'b']), identify(['ab'])); 144 + }); 145 + 146 + // ~> Objects 147 + 148 + Deno.test('objects :: basic equality', () => { 47 149 assertEquals(identify({ foo: 'bar' }), identify({ foo: 'bar' })); 48 150 }); 49 151 50 - Deno.test('objects :: key ordering', () => { 152 + Deno.test('objects :: empty', () => { 153 + assertEquals(identify({}), identify({})); 154 + assertEquals(identify({}), identify({ a: undefined })); 155 + }); 156 + 157 + Deno.test('objects :: undefined values are dropped', () => { 158 + assertEquals( 159 + identify({ a: 1, c: undefined, b: 'hello' }), 160 + identify({ a: 1, b: 'hello' }), 161 + ); 162 + }); 163 + 164 + Deno.test('objects :: key order does not matter', () => { 51 165 assertEquals( 52 166 identify({ one: 'one', two: 'two' }), 53 167 identify({ two: 'two', one: 'one' }), 54 168 ); 55 169 }); 56 170 57 - Deno.test('objects :: complex keys', () => { 58 - const d = Date.now(); 59 - assertEquals( 60 - identify({ [123]: 'one', [d]: 'two' }), 61 - identify({ 62 - [123]: 'one', 63 - [d]: 'two', 64 - }), 65 - ); 171 + Deno.test('objects :: numeric-like keys order independent', () => { 172 + assertEquals(identify({ 10: 'a', 2: 'b' }), identify({ 2: 'b', 10: 'a' })); 66 173 }); 67 174 68 - Deno.test('objects :: nested', () => { 175 + Deno.test('objects :: nested, key order does not matter at any depth', () => { 69 176 assertEquals( 70 177 identify({ a: { b: 'c' }, d: { e: { f: 'g' } } }), 71 - identify({ 72 - d: { e: { f: 'g' } }, 73 - a: { b: 'c' }, 74 - }), 178 + identify({ d: { e: { f: 'g' } }, a: { b: 'c' } }), 75 179 ); 76 180 }); 77 181 182 + Deno.test('objects :: different values differ', () => { 183 + assertNotEquals(identify({ a: 1, b: 2 }), identify({ a: 1, b: 3 })); 184 + assertNotEquals(identify({ a: 1 }), identify({ a: 1, b: 2 })); 185 + }); 186 + 78 187 Deno.test('objects :: circular', () => { 79 - const o = { a: 'b' }; 80 - // @ts-expect-error circular 81 - o['c'] = o; 188 + const o: Record<string, unknown> = { a: 'b' }; 189 + o.c = o; 82 190 assert(identify(o)); 83 191 assertEquals(identify(o), identify(o)); 84 192 }); ··· 89 197 assertEquals(identify(a), identify(a)); 90 198 }); 91 199 92 - // Known limitation: cycle detection keys off object reference identity, so two 93 - // structurally-different cyclic shapes that share a referenced node can collapse 94 - // to the same identity. Left ignored until the circular-handling overhaul. 95 - Deno.test.ignore('objects :: with samey circular should not match', () => { 96 - const o1: any = { a: 1, b: 2 }; 97 - const o2: any = { a: 1, b: 2 }; 98 - o1['c'] = o1; 99 - o1['d'] = o2; 200 + Deno.test('objects :: class instances hash by their own keys', () => { 201 + class Foo { 202 + a = 1; 203 + b = 2; 204 + } 205 + assertEquals(identify(new Foo()), identify(new Foo())); 206 + assertEquals(identify(new Foo()), identify({ a: 1, b: 2 })); 207 + }); 100 208 101 - o2['c'] = o2; // 👈 #1 102 - 103 - const a = identify(o1); 104 - 105 - o2['c'] = o1; // 👈 different from #1 106 - 107 - const b = identify(o1); 209 + Deno.test('objects :: null-prototype objects are supported', () => { 210 + const nullProto = Object.assign(Object.create(null), { a: 1, b: 2 }); 211 + assertEquals(identify(nullProto), identify({ a: 1, b: 2 })); 212 + }); 108 213 109 - assertNotEquals(a, b, `${a} != ${b}`); 214 + Deno.test('objects :: a literal "constructor" key still hashes', () => { 215 + assertEquals(identify({ constructor: 1 }), identify({ constructor: 1 })); 216 + assertNotEquals(identify({ constructor: 1 }), identify({ constructor: 2 })); 110 217 }); 111 218 112 - Deno.test('objects :: same values between types shouldnt match', () => { 113 - assertNotEquals(identify({ a: 'b' }), identify(['a', 'b'])); 219 + Deno.test('objects :: null object and null property', () => { 220 + assertEquals(identify(null), identify(null)); 221 + assertEquals(identify({ f: null }), identify({ f: null })); 222 + // A null value is distinct from a missing key. 223 + assertNotEquals(identify({ f: null }), identify({})); 114 224 }); 115 225 116 - Deno.test('objects :: same hash for Map or Object', () => { 117 - assertEquals(identify({ a: 'b' }), identify(new Map([['a', 'b']]))); 226 + Deno.test('objects :: deterministic key sorting', () => { 227 + assertEquals(identify({ b: 2, c: 3, a: 1 }), identify({ a: 1, b: 2, c: 3 })); 118 228 }); 119 229 230 + // ~> Sets 231 + 120 232 Deno.test('sets :: order is significant', () => { 121 - assertNotEquals( 122 - identify(new Set([1, 2, 3])), 123 - identify(new Set([3, 2, 1])), 124 - ); 233 + assertNotEquals(identify(new Set([1, 2, 3])), identify(new Set([3, 2, 1]))); 125 234 }); 126 235 127 236 Deno.test('sets :: order is significant for object members', () => { ··· 131 240 ); 132 241 }); 133 242 243 + Deno.test('sets :: nested', () => { 244 + assertEquals( 245 + identify(new Set([new Set([1]), new Set([2])])), 246 + identify(new Set([new Set([1]), new Set([2])])), 247 + ); 248 + }); 249 + 134 250 Deno.test('sets :: circular', () => { 135 - const s = new Set([1, 2, 3]); 136 - // @ts-expect-error circular 251 + const s: Set<unknown> = new Set([1, 2, 3]); 137 252 s.add(s); 138 253 assert(identify(s)); 139 254 assertEquals(identify(s), identify(s)); 140 255 }); 141 256 142 - Deno.test('maps :: basic', () => { 257 + // ~> Maps 258 + 259 + Deno.test('maps :: key order does not matter', () => { 143 260 assertEquals( 144 - identify( 145 - new Map([ 146 - ['a', 'b'], 147 - ['c', 'd'], 148 - ]), 149 - ), 150 - identify( 151 - new Map([ 152 - ['c', 'd'], 153 - ['a', 'b'], 154 - ]), 155 - ), 261 + identify(new Map([['a', 'b'], ['c', 'd']])), 262 + identify(new Map([['c', 'd'], ['a', 'b']])), 263 + ); 264 + }); 265 + 266 + Deno.test('maps :: single entry (fast path) is stable', () => { 267 + assertEquals(identify(new Map([['z', 1]])), identify(new Map([['z', 1]]))); 268 + }); 269 + 270 + Deno.test('maps :: mixed key types, order independent', () => { 271 + assertEquals( 272 + identify(new Map<unknown, unknown>([['a', 1], [2, 'b'], ['c', 3]])), 273 + identify(new Map<unknown, unknown>([[2, 'b'], ['c', 3], ['a', 1]])), 274 + ); 275 + }); 276 + 277 + Deno.test('maps :: different values differ', () => { 278 + assertNotEquals( 279 + identify(new Map([['a', 1]])), 280 + identify(new Map([['a', 2]])), 156 281 ); 157 282 }); 158 283 159 284 Deno.test('maps :: circular', () => { 160 - const m = new Map([ 161 - ['a', 'b'], 162 - ['c', 'd'], 163 - ]); 164 - // @ts-expect-error circular 285 + const m: Map<string, unknown> = new Map([['a', 'b'], ['c', 'd']]); 165 286 m.set('e', m); 166 287 assert(identify(m)); 167 288 assertEquals(identify(m), identify(m)); 168 289 }); 169 290 170 - Deno.test('values :: primitives', () => { 171 - const t = (v: any) => 172 - assertEquals( 173 - identify(v), 174 - identify(v), 175 - `Value ${v} should have hashed correctly.`, 176 - ); 291 + // ~> Cross-type behaviour 177 292 178 - t('test'); 179 - t(new Date()); 180 - t(NaN); 181 - t(true); 182 - t(false); 183 - t(/test/); 184 - t(123); 185 - t(null); 186 - t(undefined); 293 + Deno.test('cross-type :: a Map matches the equivalent plain object', () => { 294 + assertEquals(identify({ a: 'b' }), identify(new Map([['a', 'b']]))); 295 + assertEquals( 296 + identify({ a: 1, b: 2 }), 297 + identify(new Map<string, number>([['a', 1], ['b', 2]])), 298 + ); 187 299 }); 188 300 189 - Deno.test('values :: throws on unsupported builtins', () => { 190 - assertThrows(() => identify(new WeakMap())); 191 - assertThrows(() => identify(Promise.resolve())); 192 - assertThrows(() => identify(new Uint8Array([1, 2, 3]))); 301 + Deno.test('cross-type :: objects and arrays are distinct', () => { 302 + assertNotEquals(identify({ a: 'b' }), identify(['a', 'b'])); 303 + assertNotEquals(identify({}), identify([])); 193 304 }); 194 305 195 - Deno.test('values :: hashes plain-shaped objects', () => { 196 - class Foo { 197 - a = 1; 198 - b = 2; 199 - } 200 - assertEquals(identify(new Foo()), identify(new Foo())); 201 - assertEquals(identify(new Foo()), identify({ a: 1, b: 2 })); 306 + Deno.test('cross-type :: a Set is distinct from an Array', () => { 307 + assertNotEquals(identify(new Set([1, 2, 3])), identify([1, 2, 3])); 308 + }); 202 309 203 - const nullProto = Object.assign(Object.create(null), { a: 1, b: 2 }); 204 - assertEquals(identify(nullProto), identify({ a: 1, b: 2 })); 205 - 206 - // A literal `constructor` key must still hash, not throw. 207 - assertEquals(identify({ constructor: 1 }), identify({ constructor: 1 })); 310 + Deno.test('cross-type :: a typed array matches the equivalent index-keyed object', () => { 311 + assertEquals(identify(new Uint8Array([1, 2])), identify({ 0: 1, 1: 2 })); 312 + assertEquals(identify(new Uint8Array([])), identify({})); 313 + assertEquals(identify(new Uint8Array([1, 2])), identify(new Int16Array([1, 2]))); 208 314 }); 209 315 210 - Deno.test('values :: circular ref should be consistent', () => { 211 - let o: any = { a: 1, c: 2 }; 316 + // ~> Circular & shared references 317 + 318 + Deno.test('refs :: circular ids are consistent regardless of visitation order', () => { 319 + let o: Record<string, unknown> = { a: 1, c: 2 }; 212 320 o.b = o; 213 321 o.d = new Map(); // the map is seen 2nd 214 - o.d.set('x', o.d); 215 - 322 + (o.d as Map<string, unknown>).set('x', o.d); 216 323 assertEquals(Object.keys(o), ['a', 'c', 'b', 'd']); 217 - 218 324 const a = identify(o); 219 325 220 326 o = { a: 1 }; 221 327 o.d = new Map(); // the map is seen first 222 - o.d.set('x', o.d); 328 + (o.d as Map<string, unknown>).set('x', o.d); 223 329 o.b = o; 224 330 o.c = 2; 225 - 226 331 assertEquals(Object.keys(o), ['a', 'd', 'b', 'c']); 227 - 228 332 const b = identify(o); 229 333 230 - // the circular ref should be the same 231 334 assertEquals(a, b, `${a} === ${b}`); 232 335 }); 233 336 234 - Deno.test('values :: circular deeply nested objects should equal', () => { 235 - const o1: any = { 236 - b: { 237 - c: 123, 238 - }, 337 + Deno.test('refs :: deeply nested circular objects are equal', () => { 338 + const build = () => { 339 + const o: Record<string, any> = { b: { c: 123 } }; 340 + o.b.d = o; 341 + o.x = [9, o.b]; 342 + return o; 239 343 }; 240 - 241 - o1.b.d = o1; 242 - o1.x = [9, o1.b]; 243 - 244 - const o2: any = { 245 - b: { 246 - c: 123, 247 - }, 248 - }; 249 - 250 - o2.b.d = o2; 251 - o2.x = [9, o2.b]; 252 - const a = identify(o1); 253 - const b = identify(o2); 254 - assertEquals(a, b, `${a} === ${b}`); 344 + assertEquals(identify(build()), identify(build())); 255 345 }); 256 346 257 - Deno.test('values :: all elements visited', () => { 258 - const c = [1]; 259 - // @ts-expect-error circular 260 - c.push(c); 261 - const hash = identify({ 262 - a: { b: ['c', new Set(['d', new Map([['e', 'f']]), c, 'g'])] }, 263 - }); 264 - assertEquals(hash, 'oaobacadoefa1~5g'); 347 + Deno.test('refs :: a shared reference equals two distinct equal instances', () => { 348 + const shared = { v: 1 }; 349 + assertEquals( 350 + identify({ x: shared, y: shared }), 351 + identify({ x: { v: 1 }, y: { v: 1 } }), 352 + ); 265 353 }); 266 354 267 - Deno.test('values :: should only be seen once', () => { 355 + Deno.test('refs :: each distinct node is only back-referenced when reused', () => { 268 356 const hash = identify({ 269 357 a: [[1], [2], [3]], 270 358 b: new Set([new Set([1]), new Set([2]), new Set([3])]), ··· 272 360 assertNotMatch(hash, /~\d+/); 273 361 }); 274 362 275 - Deno.test('objects :: shared (non-circular) references are stable', () => { 276 - const shared = { v: 1 }; 277 - // Sharing one instance must hash the same as two distinct equal instances. 363 + Deno.test('refs :: circular reference to root', () => { 364 + const o: any = { id: 123 }; 365 + o.self = o; 366 + assert(identify(o)); 367 + assertEquals(identify(o), identify(o)); 368 + }); 369 + 370 + Deno.test('refs :: nested circular reference to root', () => { 371 + // A back-reference buried under a node, plus an awkward string to escape. 372 + const o: any = { label: 'a\n\t"b' }; 373 + o.inner = { root: o }; 374 + assertEquals(identify(o), identify(o)); 375 + }); 376 + 377 + Deno.test('refs :: child circular reference', () => { 378 + const o: any = { id: 1, child: { id: 2 } }; 379 + o.child.self = o.child; 380 + assertEquals(identify(o), identify(o)); 381 + }); 382 + 383 + Deno.test('refs :: nested child circular reference', () => { 384 + const o: any = { id: 1, child: { id: 2 } }; 385 + o.child.wrap = { ref: o.child }; 386 + assertEquals(identify(o), identify(o)); 387 + }); 388 + 389 + Deno.test('refs :: circular objects in an array', () => { 390 + const o: any = { id: 123 }; 391 + o.list = [o, o]; 392 + assertEquals(identify(o), identify(o)); 393 + }); 394 + 395 + Deno.test('refs :: nested circular references in an array', () => { 396 + const build = () => { 397 + const o: any = { items: [{ foo: 1 }, { bar: 2 }] }; 398 + o.items[0].self = o.items[0]; 399 + o.items[1].self = o.items[1]; 400 + return o; 401 + }; 402 + assertEquals(identify(build()), identify(build())); 403 + }); 404 + 405 + Deno.test('refs :: circular arrays', () => { 406 + const fixture: any = []; 407 + fixture.push(fixture, fixture); 408 + assert(identify(fixture)); 409 + assertEquals(identify(fixture), identify(fixture)); 410 + }); 411 + 412 + Deno.test('refs :: nested circular arrays', () => { 413 + const build = () => { 414 + const arr: any = []; 415 + arr.push({ foo: 1, back: arr }, { bar: 2, back: arr }); 416 + return arr; 417 + }; 418 + assertEquals(identify(build()), identify(build())); 419 + }); 420 + 421 + Deno.test('refs :: repeated non-circular references in objects', () => { 422 + // One shared node behind three keys hashes like three separate equal nodes. 423 + const shared = { foo: 1, bar: 'abc' }; 424 + assertEquals( 425 + identify({ a: shared, b: shared, c: shared }), 426 + identify({ 427 + a: { foo: 1, bar: 'abc' }, 428 + b: { foo: 1, bar: 'abc' }, 429 + c: { foo: 1, bar: 'abc' }, 430 + }), 431 + ); 432 + }); 433 + 434 + Deno.test('refs :: repeated non-circular references in arrays', () => { 435 + const shared = { foo: 1 }; 278 436 assertEquals( 279 - identify({ x: shared, y: shared }), 280 - identify({ x: { v: 1 }, y: { v: 1 } }), 437 + identify([shared, shared]), 438 + identify([{ foo: 1 }, { foo: 1 }]), 281 439 ); 282 440 }); 283 441 284 - Deno.test('objects :: deeply nested input does not overflow', () => { 285 - let root: any = {}; 442 + // ~> On-the-wire format 443 + // 444 + // These lock the exact serialization. A change here is a breaking change to every 445 + // previously-stored identity, so update deliberately. 446 + 447 + Deno.test('format :: circular array back-reference', () => { 448 + const arr: unknown[] = [1, 2, 3]; 449 + arr.push(arr); 450 + assertEquals(identify(arr), 'an1n2n3~1'); 451 + }); 452 + 453 + Deno.test('format :: every element is visited exactly once', () => { 454 + const c: unknown[] = [1]; 455 + c.push(c); 456 + const hash = identify({ 457 + a: { b: ['c', new Set(['d', new Map([['e', 'f']]), c, 'g'])] }, 458 + }); 459 + assertEquals(hash, 'oaobascesdoesfan1~5sg'); 460 + }); 461 + 462 + // ~> Robustness 463 + 464 + Deno.test('robustness :: very deep input does not overflow', () => { 465 + let root: Record<string, any> = {}; 286 466 let node = root; 287 467 for (let i = 0; i < 1000; i++) { 288 468 node.i = i; ··· 293 473 assertEquals(identify(root), identify(root)); 294 474 }); 295 475 296 - Deno.test('objects :: realistic payload, key order never matters', () => { 297 - assertEquals( 298 - identify({ 299 - id: 12345, 300 - name: 'a product', 301 - tags: ['a', 'b', 'c'], 302 - meta: { created: '2024-01-01', active: true, count: 42 }, 303 - }), 304 - identify({ 305 - meta: { count: 42, active: true, created: '2024-01-01' }, 306 - tags: ['a', 'b', 'c'], 307 - name: 'a product', 308 - id: 12345, 309 - }), 310 - ); 476 + Deno.test('robustness :: unsupported builtins throw', () => { 477 + assertThrows(() => identify(new WeakMap())); 478 + assertThrows(() => identify(new WeakSet())); 479 + assertThrows(() => identify(Promise.resolve())); 480 + assertThrows(() => identify(new ArrayBuffer(8))); 311 481 }); 312 482 313 - Deno.test('maps :: mixed key types, order independent', () => { 314 - assertEquals( 315 - identify(new Map<unknown, unknown>([['a', 1], [2, 'b'], ['c', 3]])), 316 - identify(new Map<unknown, unknown>([[2, 'b'], ['c', 3], ['a', 1]])), 317 - ); 483 + Deno.test('robustness :: does not mutate the input', () => { 484 + const child: any = { foo: 1 }; 485 + child.self = child; 486 + const o: any = { a: child, b: child }; 487 + identify(o); 488 + assertEquals(Object.keys(o), ['a', 'b']); 489 + assert(o.a === child); 490 + assert(o.b === child); 491 + assert(child.self === child); 492 + }); 493 + 494 + Deno.test('robustness :: hashing never pollutes Object.prototype', () => { 495 + identify(JSON.parse('{"__proto__":{"polluted":true}}')); 496 + identify(JSON.parse('{"constructor":{"prototype":{"polluted":true}}}')); 497 + assertEquals(({} as Record<string, unknown>).polluted, undefined); 498 + }); 499 + 500 + // ~> Known limitation 501 + 502 + // Cycle detection keys off object reference identity, so two structurally-different 503 + // cyclic shapes that share a referenced node can collapse to the same identity. 504 + Deno.test.ignore('limitation :: samey circular shapes should not match', () => { 505 + const o1: any = { a: 1, b: 2 }; 506 + const o2: any = { a: 1, b: 2 }; 507 + o1.c = o1; 508 + o1.d = o2; 509 + o2.c = o2; 510 + const a = identify(o1); 511 + o2.c = o1; 512 + const b = identify(o1); 513 + assertNotEquals(a, b, `${a} != ${b}`); 318 514 }); 319 515 320 516 // ~> Property based tests ··· 361 557 return o; 362 558 } 363 559 364 - Deno.test('fuzz :: identify is deterministic', () => { 560 + Deno.test('fuzz :: identify always returns a string and is idempotent', () => { 365 561 fc.assert( 366 562 fc.property(jsonish, (v) => { 367 563 const hash = identify(v); ··· 377 573 { numRuns: 1000 }, 378 574 ); 379 575 }); 576 + 577 + Deno.test('fuzz :: an object and the equivalent Map share an identity', () => { 578 + const flatObj = fc.dictionary(keyArb, leafArb, { maxKeys: 6 }); 579 + fc.assert( 580 + fc.property(flatObj, (o) => { 581 + const asMap = new Map(Object.entries(o)); 582 + return identify(o) === identify(asMap); 583 + }), 584 + { numRuns: 500 }, 585 + ); 586 + }); 587 + 588 + Deno.test('fuzz :: reversing a distinct-element array changes the identity', () => { 589 + // Distinct integers have distinct identities, so a length >= 2 reversal differs. 590 + const distinct = fc.uniqueArray(fc.integer(), { minLength: 2, maxLength: 8 }); 591 + fc.assert( 592 + fc.property(distinct, (xs) => identify(xs) !== identify([...xs].reverse())), 593 + { numRuns: 500 }, 594 + ); 595 + }); 596 + 597 + Deno.test('fuzz :: adding a defined key changes the identity', () => { 598 + const flatObj = fc.dictionary(keyArb, leafArb, { maxKeys: 5 }); 599 + fc.assert( 600 + fc.property(flatObj, fc.string({ minLength: 1 }), leafArb, (o, k, v) => { 601 + if (k in o || v === undefined) return true; // a dropped/undefined value is a no-op 602 + const before = identify(o); 603 + return before !== identify({ ...o, [k]: v }); 604 + }), 605 + { numRuns: 500 }, 606 + ); 607 + });
+40 -23
lib/mod.ts
··· 1 1 function walk(input: any, seen: any[], depth: number): string { 2 - if (input == null || typeof input !== 'object') return '' + input; 2 + if (input === null) return 'L'; 3 3 4 - if (input instanceof Date) return 'd' + +input; 5 - if (input instanceof RegExp) return 'r' + input.source + input.flags; 4 + let type = typeof input; 5 + if (type !== 'object') { 6 + if (type === 'number') return input - input === 0 ? 'n' + input : 'L'; 7 + if (type === 'string') return 's' + input; 8 + if (type === 'bigint') return 'n' + input; 9 + if (type === 'boolean') return input ? 'T' : 'F'; 10 + if (type === 'undefined') return 'U'; 11 + return '' + input; 12 + } 13 + 14 + // Arrays are the most common container, so settle them before the rarer 15 + // Date/RegExp leaves to keep the hot path short. 16 + let is_arr = Array.isArray(input); 17 + if (!is_arr) { 18 + if (input instanceof Date) return 'd' + +input; 19 + if (input instanceof RegExp) return 'r' + input.source + input.flags; 20 + } 6 21 7 22 let ref: any = seen.indexOf(input); 8 23 if (~ref) return (ref = seen[ref + 1]) > 0 ? '~' + ref : ref; 9 24 ref = seen.push(input, ++depth) - 1; 10 25 11 - let out: string, i = 0, keys: any; 26 + let out: string, i = 0, keys: any, tmp: any; 12 27 13 - if (Array.isArray(input)) { 14 - for (out = 'a'; i < input.length; out += walk(input[i++], seen, depth)); 28 + if (is_arr) { 29 + for ( 30 + out = 'a'; 31 + i < input.length; 32 + out += (tmp = input[i++]) === undefined ? 'L' : walk(tmp, seen, depth) 33 + ); 15 34 } else if (input instanceof Set) { 16 - out = 'a'; 17 - for (let value of input) out += walk(value, seen, depth); 35 + out = 'e'; 36 + for (let value of input) out += value === undefined ? 'L' : walk(value, seen, depth); 18 37 } else if (input instanceof Map) { 19 38 out = 'o'; 20 39 if (input.size > 1) { 21 - for ( 22 - keys = [...input.keys()].sort(); 23 - i < keys.length; 24 - out += keys[i] + walk(input.get(keys[i++]), seen, depth) 25 - ); 40 + for (keys = [...input.keys()].sort(); i < keys.length; i++) { 41 + ((tmp = input.get(keys[i])) !== undefined) && (out += keys[i] + walk(tmp, seen, depth)); 42 + } 26 43 } else { 27 - for (keys of input) out += keys[0] + walk(keys[1], seen, depth); 44 + for (keys of input) { 45 + ((tmp = keys[1]) !== undefined) && (out += keys[0] + walk(tmp, seen, depth)); 46 + } 28 47 } 29 48 } // Plain objects, class instances and null-prototype objects have no 30 - // `Symbol.toStringTag`; exotic builtins (Promise, typed arrays, WeakMap, 31 - // ArrayBuffer, etc) do, so this both selects key-walkable objects and 32 - // rejects the unsupported ones below. 33 - else if (input[Symbol.toStringTag] === undefined) { 49 + // `Symbol.toStringTag`. Typed arrays (and other ArrayBuffer views) do, but 50 + // JSON treats them as index-keyed objects, so we walk their keys the same way. 51 + // Other exotic builtins (Promise, WeakMap, ArrayBuffer) fall through to the throw. 52 + else if (input[Symbol.toStringTag] === undefined || ArrayBuffer.isView(input)) { 34 53 out = 'o'; 35 54 keys = Object.keys(input); 36 - if (keys.length === 1) { 37 - out += keys[0] + walk(input[keys[0]], seen, depth); 38 - } else { 39 - if (keys.length > 1) keys.sort(); 40 - for (; i < keys.length; out += keys[i] + walk(input[keys[i++]], seen, depth)); 55 + if (keys.length > 1) keys.sort(); 56 + for (; i < keys.length; i++) { 57 + if ((tmp = input[keys[i]]) !== undefined) out += keys[i] + walk(tmp, seen, depth); 41 58 } 42 59 } else { 43 60 throw new Error('Unsupported value');