From 872348932e2994a87e942847c092a45c47e68eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:32:55 +0000 Subject: [PATCH 1/6] types/object: add structurallySame and assertStructurallySame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proofs comparing two independently constructed values had no structural comparison to reach for, so they either hand-rolled one (rtti/parse's private assertDeepEqual) or compared JSON.stringify output, which makes property order observable, drops undefined-valued properties, and collapses NaN and -0. structurallySame lives in a dependency-free leaf, fjs/types/object/ structurally_same, because fjs/asserts needs it and the public object module reaches fjs/asserts through types/nullable — importing it there would close the cycle asserts -> object -> nullable -> asserts. The object module re-exports it as its public home; fjs/asserts imports the leaf directly. Converted the consumers where serialization was only a comparison mechanism: rtti/parse's assertDeepEqual and assertErrorPath, and the stringify-vs-stringify sites in cas/evo and bnf proofs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- fjs/asserts/module.f.mjs | 20 ++ fjs/asserts/proof.f.mjs | 16 +- fjs/bnf/proof.f.mjs | 8 +- fjs/bnf/todo/serialized-proof-expectations.md | 107 +++++++++ fjs/cas/evo/proof.f.mjs | 8 +- fjs/types/object/module.f.mjs | 12 +- fjs/types/object/structurally_same/README.md | 55 +++++ .../object/structurally_same/module.f.mjs | 58 +++++ .../object/structurally_same/proof.f.mjs | 61 +++++ fjs/types/object/todo/structurally-same.md | 210 ------------------ fjs/types/rtti/parse/proof.f.mjs | 56 ++--- fjs/types/rtti/todo/proof-shared-asserts.md | 29 ++- 12 files changed, 374 insertions(+), 266 deletions(-) create mode 100644 fjs/bnf/todo/serialized-proof-expectations.md create mode 100644 fjs/types/object/structurally_same/README.md create mode 100644 fjs/types/object/structurally_same/module.f.mjs create mode 100644 fjs/types/object/structurally_same/proof.f.mjs delete mode 100644 fjs/types/object/todo/structurally-same.md diff --git a/fjs/asserts/module.f.mjs b/fjs/asserts/module.f.mjs index 83f0599bf..e9f4d9304 100644 --- a/fjs/asserts/module.f.mjs +++ b/fjs/asserts/module.f.mjs @@ -4,6 +4,8 @@ * @module */ +import { structurallySame } from '../types/object/structurally_same/module.f.mjs' + /** * Marks a code path as unimplemented. Always throws. * @type {() => never} @@ -33,6 +35,24 @@ export const assertEq = (...x) => { assert(a === b, x) } +/** + * Asserts that `a` and `b` are structurally the same — equal leaves under the + * same shape, property order irrelevant — throwing `x` (the `[a, b]` pair, plus + * an optional third element used as an extra message) if they differ. + * + * This is the assertion to reach for when comparing two independently + * constructed values. `assertEq` is `===`, so it only ever answers "the same + * reference?" for objects, which is why proofs fell back to comparing + * `JSON.stringify` output; see `types/object/structurally_same/README.md` for + * why that is the wrong question and what this one does *not* cover. + * + * @type {(...x: readonly[unknown, unknown, unknown?]) => void} + */ +export const assertStructurallySame = (...x) => { + const [a, b] = x + assert(structurallySame(a, b), x) +} + /** * Asserts that `a` is neither `null` nor `undefined` and returns it, * narrowed to `T`. diff --git a/fjs/asserts/proof.f.mjs b/fjs/asserts/proof.f.mjs index f6eb2bf3b..5a13285b3 100644 --- a/fjs/asserts/proof.f.mjs +++ b/fjs/asserts/proof.f.mjs @@ -1,4 +1,9 @@ -import { assert, assertEq, todo } from './module.f.mjs' +import { + assert, + assertEq, + assertStructurallySame, + todo, +} from './module.f.mjs' export const proof = { assertPassesOnTrue: () => { @@ -9,9 +14,18 @@ export const proof = { assertEq(1, 1) assertEq('x', 'x') }, + assertStructurallySamePassesOnSame: () => { + // the case `assertEq` cannot do: two separately built values + assertStructurallySame({ a: 1, b: [2, { c: 3 }] }, { b: [2, { c: 3 }], a: 1 }) + assertStructurallySame(1, 1, 'with message') + }, throw: { assertEqThrowsOnUnequal: () => assertEq(1, 2), assertEqThrowsOnUnequal3: () => assertEq(1, 2, "message"), + assertStructurallySameThrowsOnDifferent: + () => assertStructurallySame({ a: 1 }, { a: 2 }), + assertStructurallySameThrowsOnDifferent3: + () => assertStructurallySame({ a: 1 }, { a: 2 }, 'message'), assertThrowsDefaultMsg: () => assert(false), assertThrowsCustomMsg: () => assert(false, 'oops'), todoThrows: () => todo(), diff --git a/fjs/bnf/proof.f.mjs b/fjs/bnf/proof.f.mjs index cc83fabd0..ec2279664 100644 --- a/fjs/bnf/proof.f.mjs +++ b/fjs/bnf/proof.f.mjs @@ -4,7 +4,11 @@ * @import { Rule } from './types.ts' */ -import { assert, assertEq } from '../asserts/module.f.mjs' +import { + assert, + assertEq, + assertStructurallySame, +} from '../asserts/module.f.mjs' import { eof, eofSymbol, @@ -102,7 +106,7 @@ export const proof = { () => { const r = definedValues(notSet('a')) const decoded = r.map(rangeDecode) - assertEq(JSON.stringify(decoded), JSON.stringify([[0, 0x60], [0x62, maxSymbol]])) + assertStructurallySame(decoded, [[0, 0x60], [0x62, maxSymbol]]) }, ], str: [ diff --git a/fjs/bnf/todo/serialized-proof-expectations.md b/fjs/bnf/todo/serialized-proof-expectations.md new file mode 100644 index 000000000..addee1a4c --- /dev/null +++ b/fjs/bnf/todo/serialized-proof-expectations.md @@ -0,0 +1,107 @@ +## Replace serialized proof expectations with structural ones + +**Priority:** P4 +**Status:** open + +### Problem + +Roughly 105 proof assertions compare `JSON.stringify(value)` against a JSON +**string literal** instead of stating the expected value directly: + +| File | sites | +| ---- | ----- | +| `fjs/djs/tokenizer/proof.f.mjs` | ~14 | +| `fjs/bnf/ll1/proof.f.mjs` | 34 | +| `fjs/bnf/descent/proof.f.mjs` | 27 | +| `fjs/bnf/data/proof.f.mjs` | 4 | +| `fjs/media/json/serializer/proof.f.mjs` | 11 (serialization **is** the contract — leave alone) | + +```js +const result = JSON.stringify(dm) +if (result !== '{"":{"rangeMap":[[null,64],[{"rules":[]},70]]}}') { throw result } +``` + +Serialization is incidental here — the proof wants "is this the dispatch map I +expect?", not "does it serialize to this text". The string form makes property +order observable, drops `undefined`-valued properties, and forces the reader to +parse JSON in their head to see what is being claimed. + +`structurallySame` / `assertStructurallySame` (added in the PR that filed this +issue, see `fjs/types/object/structurally_same/README.md`) is the comparison +these sites want. The two sites where serialization was *only* a comparison +mechanism — `fjs/cas/evo/proof.f.mjs` and `fjs/bnf/proof.f.mjs:105`, both +`stringify(actual)` vs `stringify(expected)` — were converted there. These +remaining ones were not, for the reason below. + +### The obstacle: `undefined`-valued properties + +A mechanical rewrite of the string literal into the equivalent JavaScript +literal **does not pass**, and this is not a typo-level problem. The BNF +dispatch entries carry optional properties as *present with value +`undefined`*, which `JSON.stringify` silently drops: + +```js +const dm = dispatchMap(toData(range('AF'))[0]) +Object.keys(dm['']) // ['emptyTag', 'rangeMap'] — emptyTag is present, and undefined +``` + +So `{"":{"rangeMap":[…]}}` is a *lossy projection* of the real value. Under +`structurallySame`, `{ a: undefined }` and `{}` deliberately differ (a property +is a property), so the honest literal is +`{ '': { emptyTag: undefined, rangeMap: […] } }` — every expectation gains +`emptyTag: undefined` / `tag: undefined` noise that says nothing about the +grammar under test. That is not obviously an improvement over the JSON string, +which is why this needs a design decision rather than a mechanical pass. + +A second, smaller loss: `fjs/bnf/data/proof.f.mjs`'s `emptyTagMap` expectations +(`'{"5":true,"":"e"}'`) are order-sensitive today purely because they are +strings. `structurallySame` ignores property order by design, so converting +them silently drops an assertion nobody wrote on purpose — fine if intended, +but it should be intended. + +### Proposal + +Decide which of these is true, then apply it uniformly: + +1. **The `undefined` properties are the defect.** `dispatchMap` and friends + should not emit `emptyTag: undefined` / `tag: undefined` at all — omit the + key instead. Then the mechanical rewrite works, the expectations read + cleanly, and the data structures stop carrying properties that mean + "absent". Check what consumes `emptyTag` before changing its shape. +2. **The `undefined` properties are intended**, and the proofs should spell + them out. Verbose but honest; the expectation then documents the real value. +3. **`structurallySame` should treat an `undefined`-valued property as + absent.** This would match how the repo already treats `StringMap` — + `{readonly[k in string]?: T}`, iterated with `definedEntries` / + `definedValues` precisely because an `undefined` value is not an entry + (AGENTS.md §6.2). It contradicts the semantics `structurallySame` shipped + with, so it is a breaking change to that helper and needs its own argument, + not a drive-by flip. Note that option 3 also removes the reason option 1 + exists, so pick one, not both. + +Option 1 is the most likely right answer — it fixes the data rather than the +comparison — but it is a change to `fjs/bnf`, not to the proofs, and should be +measured against what reads `emptyTag`. + +### Tasks + +- [ ] Decide between the three options above; record the reasoning in + `fjs/bnf/README.md` (option 1/2) or + `fjs/types/object/structurally_same/README.md` (option 3). +- [ ] Convert the `fjs/bnf/ll1`, `fjs/bnf/descent`, `fjs/bnf/data` and + `fjs/djs/tokenizer` expectations accordingly. +- [ ] Confirm the `emptyTagMap` expectations do not depend on property order, + or keep those specific ones as strings and say why. +- [ ] Leave `fjs/media/json/serializer/proof.f.mjs` as string comparisons — + serialized text is that module's contract. +- [ ] `npx tsc`, `fjs test`. + +### Related + +- `fjs/types/object/structurally_same/README.md` — the comparison these sites + should use, and what it does and does not promise. +- `fjs/cas/evo/proof.f.mjs`, `fjs/bnf/proof.f.mjs` — the two sites already + converted; both compared two *values*, so neither hit the `undefined` + problem. +- AGENTS.md §6.2 (`StringMap` / `definedEntries`) — the precedent option 3 + would be aligning with. diff --git a/fjs/cas/evo/proof.f.mjs b/fjs/cas/evo/proof.f.mjs index 8aee62dec..19d6d0c9d 100644 --- a/fjs/cas/evo/proof.f.mjs +++ b/fjs/cas/evo/proof.f.mjs @@ -7,7 +7,11 @@ * @import { RevisionData } from './types.ts' */ -import { assert, assertEq } from '../../asserts/module.f.mjs' +import { + assert, + assertEq, + assertStructurallySame, +} from '../../asserts/module.f.mjs' import { pure } from '../../effects/module.f.mjs' import { fileCas } from '../module.f.mjs' import { sha256 } from '../../crypto/sha2/module.f.mjs' @@ -65,7 +69,7 @@ export const proof = { buildCacheEmptyStoreYieldsEmptyCache: () => { const c = fileCas(sha256)(home) const [, cache] = virtual(emptyState)(buildCache(c)) - assertEq(JSON.stringify(cache), JSON.stringify(emptyCache)) + assertStructurallySame(cache, emptyCache) }, buildCacheSkipsNonRevisionBlob: () => { const c = fileCas(sha256)(home) diff --git a/fjs/types/object/module.f.mjs b/fjs/types/object/module.f.mjs index a6060062f..71ac03203 100644 --- a/fjs/types/object/module.f.mjs +++ b/fjs/types/object/module.f.mjs @@ -1,6 +1,7 @@ /** - * Plain-object helpers: safe property lookup via `at`, and conversions - * between entries and `OrderedMap`. See `./types.ts` for the + * Plain-object helpers: safe property lookup via `at`, structural comparison + * via `structurallySame`, and conversions between entries and `OrderedMap`. + * See `./types.ts` for the * `OptionalMap`/`RequiredMap`/`StringMap`/`Entry`/`OneKey`/`SingleProperty`/ * `NotUnion` type-level API. * @@ -16,6 +17,13 @@ import { entries as mapEntries, fromEntries as mapFromEntries } from '../ordered /** @import { OrderedMap } from '../ordered_map/types.ts' */ /** @import { StringMap, Entry } from './types.ts' */ +/** + * `structurallySame` is implemented in a dependency-free leaf so `fjs/asserts` + * can use it without the cycle `asserts -> object -> nullable -> asserts`; see + * `./structurally_same/README.md`. This module is its public home. + */ +export { structurallySame } from './structurally_same/module.f.mjs' + const { getOwnPropertyDescriptor, fromEntries: objectFromEntries } = Object /** @type {(name: string) => (object: StringMap) => Nullable>} */ diff --git a/fjs/types/object/structurally_same/README.md b/fjs/types/object/structurally_same/README.md new file mode 100644 index 000000000..c1b237d37 --- /dev/null +++ b/fjs/types/object/structurally_same/README.md @@ -0,0 +1,55 @@ +# Structurally same + +## Why this is its own module + +`structurallySame` is the comparison behind `assertStructurallySame` in +[`fjs/asserts`](../../../asserts/module.f.mjs), and it is also an ordinary +object helper that belongs on [`fjs/types/object`](../module.f.mjs). Those two +homes cannot both be the implementation: the object module imports +`types/nullable`, which imports `fjs/asserts`, so an assertion module importing +the object module would close the runtime cycle +`asserts -> object -> nullable -> asserts`. + +So the implementation lives here, in a leaf that imports **nothing**, and the +two consumers reach it from opposite directions — `fjs/asserts` imports it +directly, and `fjs/types/object` re-exports it as part of its public API. Adding +any import to this module re-opens the cycle; keep it a leaf. + +## What it compares, and what it does not + +The contract is FunctionalScript data — primitives, arrays, and record-like +objects: + +- `Object.is` decides first, so `NaN` is the same as `NaN`, `0` and `-0` differ, + and a value is trivially the same as itself. +- Arrays match arrays of equal length, elementwise. An array never matches a + non-array. +- Other objects match on their own enumerable string properties as a *set* — + order is not part of the structure — with every value compared recursively. + A property whose value is `undefined` is a property, so `{ a: undefined }` and + `{}` differ. + +The signature takes `unknown` because assertion and parsing boundaries have +nothing narrower to offer, but a wide input type is not a promise of wide +semantics. A date, map, set, or typed array is compared **only** by its own +enumerable string properties, which for most of them is no properties at all — +two different `Date`s read as the same. Prototypes, property descriptors, +symbol keys, and getters are all invisible here. A caller needing any of those +needs a different comparison, not a flag on this one. + +There is no cycle detection: a self-referential value recurses until the stack +runs out. FunctionalScript data is acyclic, so a seen-set would tax every real +comparison to catch a case that cannot occur. + +## Why proofs should prefer it to `JSON.stringify` + +Proofs reached for `assertEq(JSON.stringify(a), JSON.stringify(b))` because no +structural comparison existed. Serialization answers a different question and +drags in semantics the proof did not ask for: property order becomes +observable, `undefined`-valued properties vanish, `NaN` and the infinities +collapse to `null`, `-0` becomes `0`, `bigint` throws, and both sides allocate +strings only to be thrown away. + +Keep a string comparison where the serialized text *is* the contract — a +serializer's own proofs, or an API that returns text. Everywhere else, state the +expected value directly and compare it with `assertStructurallySame`. diff --git a/fjs/types/object/structurally_same/module.f.mjs b/fjs/types/object/structurally_same/module.f.mjs new file mode 100644 index 000000000..65256cefd --- /dev/null +++ b/fjs/types/object/structurally_same/module.f.mjs @@ -0,0 +1,58 @@ +/** + * Structural comparison for FunctionalScript data. + * + * `structurallySame` answers "do these two independently constructed values + * have the same shape and the same leaves?" — the question proofs really ask + * when they compare a computed value against an expected one. It is scoped to + * FunctionalScript data (primitives, arrays, and record-like objects) and + * deliberately says nothing about dates, maps, sets, typed arrays, prototypes, + * or property descriptors; see the README for what it does *not* promise. + * + * This module is a dependency-free leaf on purpose. `fjs/asserts` needs the + * comparison for `assertStructurallySame`, and the public object module + * (`../module.f.mjs`) reaches `fjs/asserts` through `types/nullable`, so + * importing anything here would close the cycle + * `asserts -> object -> nullable -> asserts`. Keep it importing nothing. + * + * @module + */ + +const { entries, is } = Object + +/** + * Compares two values structurally. + * + * - `Object.is` decides first, so `NaN` equals itself, `0` and `-0` differ, and + * an object is trivially the same as itself. + * - Anything else that is not a non-null object differs. + * - Arrays match arrays of the same length whose elements match pairwise; an + * array never matches a non-array. + * - Other objects match when their own enumerable string properties form the + * same set — order is irrelevant — and every property's value matches. A + * property whose value is `undefined` is a property: `{ a: undefined }` and + * `{}` differ. + * + * There is no cycle detection: a self-referential value recurses until the + * stack runs out. FunctionalScript data is acyclic, and adding a seen-set would + * cost every ordinary comparison for a case that cannot arise. + * + * @type {(a: unknown, b: unknown) => boolean} + */ +export const structurallySame = (a, b) => { + if (is(a, b)) { return true } + if ( + typeof a !== 'object' || a === null || + typeof b !== 'object' || b === null + ) { return false } + if (a instanceof Array) { + return b instanceof Array + && a.length === b.length + && a.every((v, i) => structurallySame(v, b[i])) + } + if (b instanceof Array) { return false } + const ae = entries(a) + /** @type {ReadonlyMap} */ + const bm = new Map(entries(b)) + return ae.length === bm.size + && ae.every(([k, v]) => bm.has(k) && structurallySame(v, bm.get(k))) +} diff --git a/fjs/types/object/structurally_same/proof.f.mjs b/fjs/types/object/structurally_same/proof.f.mjs new file mode 100644 index 000000000..6ce8820d0 --- /dev/null +++ b/fjs/types/object/structurally_same/proof.f.mjs @@ -0,0 +1,61 @@ +import { assert } from '../../../asserts/module.f.mjs' +import { structurallySame } from './module.f.mjs' + +/** @type {(a: unknown, b: unknown) => void} */ +const same = (a, b) => assert(structurallySame(a, b), ['same', a, b]) + +/** @type {(a: unknown, b: unknown) => void} */ +const differ = (a, b) => assert(!structurallySame(a, b), ['differ', a, b]) + +export const proof = { + primitives: [ + // `Object.is` decides, so `NaN` is its own equal... + () => same(Number.NaN, Number.NaN), + () => same(42, 42), + () => same('a', 'a'), + () => same(undefined, undefined), + () => same(null, null), + // ...and the two zeros are not. + () => differ(0, -0), + () => differ(1, 2), + () => differ('a', 'b'), + () => differ(null, undefined), + // a non-object on either side ends it + () => differ(1, {}), + () => differ({}, 1), + () => differ(null, {}), + () => differ({}, null), + ], + identity: [ + // the same reference short-circuits before any traversal + () => { + const a = { x: [1, 2] } + same(a, a) + }, + ], + arrays: [ + () => same([], []), + () => same([1, { a: 2 }], [1, { a: 2 }]), + // length first, then elementwise + () => differ([1], [1, 2]), + () => differ([1, 2], [2, 1]), + // an array is never the same as a non-array, in either position + () => differ([], {}), + () => differ({}, []), + ], + objects: [ + () => same({}, {}), + // property order is not part of the structure + () => same({ a: 1, b: { c: 2 } }, { b: { c: 2 }, a: 1 }), + // same count, different names + () => differ({ a: 1 }, { b: 1 }), + // same names, different values + () => differ({ a: 1 }, { a: 2 }), + () => differ({ a: { b: 1 } }, { a: { b: 2 } }), + // different counts + () => differ({ a: 1 }, { a: 1, b: 2 }), + // an `undefined`-valued property is still a property + () => differ({ a: undefined }, {}), + () => differ({}, { a: undefined }), + ], +} diff --git a/fjs/types/object/todo/structurally-same.md b/fjs/types/object/todo/structurally-same.md deleted file mode 100644 index 0918c3dc2..000000000 --- a/fjs/types/object/todo/structurally-same.md +++ /dev/null @@ -1,210 +0,0 @@ -# Add `structurallySame` and `assertStructurallySame` - -**Priority:** P3 -**Status:** open - -## Problem - -`fjs/types/rtti/parse/proof.f.mjs` contains a private `assertDeepEqual` helper for -checking parsed FunctionalScript data. It recursively compares arrays and plain -records, but it is ad hoc and cannot be reused by other proofs: - -- primitive comparison uses `===`, so it does not preserve `Object.is` behavior - for `NaN` and signed zero; -- failures are reported through bespoke `if`/`throw` branches instead of the - shared assertion module; -- the recursive comparison logic is embedded in one proof file even though the - same operation is useful whenever tests compare independently constructed - FunctionalScript data. - -Proofs also commonly serialize values only to compare their structure. For -example, `fjs/cas/evo/proof.f.mjs` compares a computed cache with `emptyCache` by -calling `JSON.stringify` on both values. The BNF proofs contain many similar -candidates where parser or dispatch results are converted to JSON strings and -compared with serialized expected values. - -Using serialization as an equality helper has unrelated semantics: - -- object property order becomes observable even when order is irrelevant; -- properties containing `undefined` can disappear; -- `NaN`, infinities, and signed zero do not preserve `Object.is` semantics; -- proofs allocate and compare intermediate strings instead of directly stating - the expected value. - -The immediate consumers are the RTTI parse proof and proofs that use a JSON -serializer only as an incidental structural-comparison mechanism. The helper is -intended for FunctionalScript-style data—primitives, arrays, and record-like -objects—not as semantic comparison for dates, maps, sets, typed arrays, or -arbitrary host objects. Accepting `unknown` is useful at assertion and parsing -boundaries, but the result only describes the structural rules below. - -The implementation placement must also avoid an import cycle. The existing -`fjs/types/object/module.f.mjs` imports `fjs/types/nullable/module.f.mjs`, which -imports `fjs/asserts/module.f.mjs`. Therefore, `asserts/module.f.mjs` cannot import -the object module to reuse `structurallySame` without creating the runtime cycle -`asserts -> object -> nullable -> asserts`. - -## Proposal - -Define the recursive comparison in a cycle-free leaf module: - -```ts -// fjs/types/object/structurally_same/module.f.mjs -export const structurallySame = (a: unknown, b: unknown): boolean => ... -``` - -The leaf must not import `fjs/asserts/module.f.mjs`, -`fjs/types/nullable/module.f.mjs`, or `fjs/types/object/module.f.mjs`. -Re-export `structurallySame` from `fjs/types/object/module.f.mjs` as the public -object-helper API. - -Add the corresponding assertion helper to `fjs/asserts/module.f.mjs`, importing -the comparison directly from the cycle-free leaf rather than from the public -object module: - -```ts -export const assertStructurallySame = - (...x: readonly [unknown, unknown, unknown?]): void => ... -``` - -The assertion name uses the comparison name as its suffix, consistently with the -`assert*` naming convention. - -`structurallySame` uses `Object.is` as the fast path, rejects distinct values -when either is not a non-null object, and recursively compares arrays and -non-null objects. -`assertStructurallySame(a, b, msg?)` returns normally when -`structurallySame(a, b)` is `true` and otherwise throws the compared values plus -the optional message, following the existing `assertEq` shape. - -Use `assertStructurallySame` in proofs when serialization is only a workaround -for comparing independently constructed values. Keep serialized-string -comparisons when serialization itself is the behavior under test, or when the -API intentionally returns serialized text. - -For example: - -```ts -assertStructurallySame(cache, emptyCache) -``` - -is preferable to: - -```ts -assertEq(JSON.stringify(cache), JSON.stringify(emptyCache)) -``` - -### Semantics - -1. Call `Object.is(a, b)` first. If it returns `true`, return `true`. - This preserves `Object.is` behavior for primitives, `NaN`, signed zero, and - identical object references. -2. If either value is not a non-null object, return `false`. -3. If only one value is an array, return `false`. -4. If both values are arrays: - - require the same `length`; - - recursively compare each value at the same index with `structurallySame`. -5. Otherwise, both values are non-null, non-array objects: - - require the same set of own enumerable string properties, ignoring order; - - recursively compare the value of every property with `structurallySame`. - -The generic `unknown` signature does not imply special semantics for every host -object. Under this algorithm, an unsupported host object is compared only by its -own enumerable string properties. Callers that require date, map, set, typed-array, -prototype, or descriptor semantics must use a more specific comparison. - -For example: - -```ts -structurallySame(NaN, NaN) // true -structurallySame(0, -0) // false -structurallySame(1, 2) // false -structurallySame({}, null) // false -structurallySame([], {}) // false - -structurallySame( - { a: 1, b: { c: 2 } }, - { b: { c: 2 }, a: 1 }, -) // true - -structurallySame({ a: undefined }, {}) // false -structurallySame({ a: 1 }, { b: 1 }) // false -structurallySame({ a: 1 }, { a: 2 }) // false -structurallySame([1, { a: 2 }], [1, { a: 2 }]) // true -structurallySame([1, 2], [2, 1]) // false -structurallySame([1], [1, 2]) // false - -assertStructurallySame( - { a: 1 }, - { a: 1 }, -) -``` - -### Initial scope - -Keep the first implementation small and suitable for FunctionalScript data: - -- compare values, not property descriptors or prototypes; -- do not add special handling for symbols, dates, maps, sets, typed arrays, or - other host objects; -- do not add cycle detection; -- array comparison is based on length and indexed values, not custom properties. - -These cases can be added later when a concrete consumer requires them. - -## Tasks - -- [ ] Add the dependency-free implementation to - `fjs/types/object/structurally_same/module.f.mjs`. -- [ ] Add the co-located `fjs/types/object/structurally_same/proof.f.mjs` module, - export `proof`, and exercise every branch of `structurallySame` there. -- [ ] Re-export `structurallySame` from `fjs/types/object/module.f.mjs`. -- [ ] Add `assertStructurallySame` to `fjs/asserts/module.f.mjs`, importing - `structurallySame` directly from the leaf module. -- [ ] Verify that the change does not introduce the - `asserts -> object -> nullable -> asserts` import cycle. -- [ ] Replace the private `assertDeepEqual` in - `fjs/types/rtti/parse/proof.f.mjs` with `assertStructurallySame`. -- [ ] Update `fjs/types/rtti/todo/proof-shared-asserts.md` when removing - `assertDeepEqual`: remove or mark that subtask complete while preserving - its remaining result-helper and shared-suite work. -- [ ] Replace proof comparisons that serialize both actual and expected values - only to compare structure, starting with `fjs/cas/evo/proof.f.mjs`. -- [ ] Audit proof files that compare a computed value with a JSON string, including - the BNF proofs; replace cases where serialized text is not the contract with - direct expected values and `assertStructurallySame`. -- [ ] Keep serializer proofs and APIs that intentionally return serialized text - as string comparisons. -- [ ] Use `Object.is` as the fast path; when it returns `false`, reject the values - if either is not a non-null object. -- [ ] Compare arrays by length and recursively by index. -- [ ] Compare object property sets without depending on property order. -- [ ] In the co-located proof module, cover identical and distinct primitives, - `NaN`, signed zero, arrays, nested values, reordered object properties, - missing properties whose value would read as `undefined`, an object versus - a primitive, and an array versus a non-array. -- [ ] In the co-located proof module, cover arrays with different lengths, objects - with the same number of properties but different property names, and objects - with the same property names but recursively different values. -- [ ] Add assertion proof cases for success, failure, and the optional message. -- [ ] Run `npx tsc` and `fjs t`. - -## Related - -- [`fjs/types/object/module.f.mjs`](../module.f.mjs) — public object-helper module; - it currently depends on `types/nullable`. -- [`fjs/types/nullable/module.f.mjs`](../../nullable/module.f.mjs) — imports the - assertion module, which makes importing the public object module from assertions - cyclic. -- [`fjs/types/rtti/parse/proof.f.mjs`](../../rtti/parse/proof.f.mjs) — contains the - private `assertDeepEqual` that is the first direct consumer. -- [`fjs/cas/evo/proof.f.mjs`](../../../cas/evo/proof.f.mjs) — compares independently - constructed cache values through `JSON.stringify`. -- [`fjs/bnf/ll1/proof.f.mjs`](../../../bnf/ll1/proof.f.mjs) and - [`fjs/bnf/descent/proof.f.mjs`](../../../bnf/descent/proof.f.mjs) — contain serialized - expected-value comparisons to audit and replace where serialization is incidental. -- [`proof-shared-asserts.md`](../../rtti/todo/proof-shared-asserts.md) — also - tracks replacing the RTTI proof's local deep-comparison helper; update that - tracker when this TODO removes `assertDeepEqual`. -- [`fjs/asserts/module.f.mjs`](../../../asserts/module.f.mjs) — existing assertion - naming and failure-shape conventions. diff --git a/fjs/types/rtti/parse/proof.f.mjs b/fjs/types/rtti/parse/proof.f.mjs index 8129bb148..e856ec3df 100644 --- a/fjs/types/rtti/parse/proof.f.mjs +++ b/fjs/types/rtti/parse/proof.f.mjs @@ -8,7 +8,11 @@ import { parse } from './module.f.mjs' import { boolean, number, string, bigint, unknown, array, record, or, option } from '../module.f.mjs' -import { assert, assertEq } from '../../../asserts/module.f.mjs' +import { + assert, + assertEq, + assertStructurallySame, +} from '../../../asserts/module.f.mjs' /** @type {(r: readonly [string, unknown]) => void} */ const assertOk = ([k]) => { assertEq(k, 'ok', 'expected ok') } @@ -31,33 +35,9 @@ const assertErrorPath = expected => r => { assert(r[0] === 'error', 'expected error') const e = /** @type {ValidationError} */ (r[1]) - if (e.path.length !== expected.length) { throw `path length ${e.path.length} != ${expected.length}` } - for (let i = 0; i < expected.length; i++) { - if (e.path[i] !== expected[i]) { throw `path[${i}] ${e.path[i]} != ${expected[i]}` } - } + assertStructurallySame(e.path, expected, 'unexpected error path') } -/** @type {(a: unknown, b: unknown) => void} */ -const assertDeepEqual = (a, b) => { - if (a === b) { return } - if (a instanceof Array && b instanceof Array) { - if (a.length !== b.length) { throw `array length ${a.length} != ${b.length}` } - for (let i = 0; i < a.length; i++) { assertDeepEqual(a[i], b[i]) } - return - } - if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) { - const ka = Object.keys(a).sort() - const kb = Object.keys(b).sort() - if (ka.length !== kb.length) { throw `key count ${ka.length} != ${kb.length}` } - for (let i = 0; i < ka.length; i++) { - if (ka[i] !== kb[i]) { throw `key ${ka[i]} != ${kb[i]}` } - assertDeepEqual((/** @type {any} */ (a))[ka[i]], (/** @type {any} */ (b))[kb[i]]) - } - return - } - throw `not deep-equal: ${String(a)} vs ${String(b)}` -} - export const proof = { boolean: { ok: () => { @@ -173,12 +153,12 @@ export const proof = { ok: () => { const t = /** @type {const} */ ([42, 'hello']) const r = parse(t)([42, 'hello']) - assertDeepEqual(unwrap(r), [42, 'hello']) + assertStructurallySame(unwrap(r), [42, 'hello']) }, // The key behavior change vs `validate`: extra tuple elements are dropped. extraItemsDropped: () => { const r = parse(/** @type {const} */ ([42]))([42, 'extra']) - assertDeepEqual(unwrap(r), [42]) + assertStructurallySame(unwrap(r), [42]) }, error: () => { assertError(parse(/** @type {const} */ ([42]))([99])) @@ -189,12 +169,12 @@ export const proof = { ok: () => { const t = /** @type {const} */ ({ a: 42, b: 'hello' }) const r = parse(t)({ a: 42, b: 'hello' }) - assertDeepEqual(unwrap(r), { a: 42, b: 'hello' }) + assertStructurallySame(unwrap(r), { a: 42, b: 'hello' }) }, // Undeclared properties are dropped from the constructed value. extraKeysDropped: () => { const r = parse(/** @type {const} */ ({ a: /** @type {const} */ (42) }))({ a: 42, b: 'extra' }) - assertDeepEqual(unwrap(r), { a: 42 }) + assertStructurallySame(unwrap(r), { a: 42 }) }, error: () => { assertError(parse(/** @type {const} */ ({ a: 42 }))({ a: 99 })) @@ -205,11 +185,11 @@ export const proof = { array: { empty: () => { const r = parse(array(number))([]) - assertDeepEqual(unwrap(r), []) + assertStructurallySame(unwrap(r), []) }, ok: () => { const r = parse(array(number))([1, 2, 3]) - assertDeepEqual(unwrap(r), [1, 2, 3]) + assertStructurallySame(unwrap(r), [1, 2, 3]) }, // `parse` always constructs a new array, even when the inner type is a primitive. freshArray: () => { @@ -217,7 +197,7 @@ export const proof = { /** @type {readonly number[]} */ const out = unwrap(parse(array(number))(input)) assert(out !== /** @type {unknown} */ (input), 'expected a fresh array') - assertDeepEqual(out, [1, 2, 3]) + assertStructurallySame(out, [1, 2, 3]) }, error: () => { assertError(parse(array(number))([1, 'two', 3])) @@ -226,18 +206,18 @@ export const proof = { }, nested: () => { const r = parse(array(array(boolean)))([[true, false], [false]]) - assertDeepEqual(unwrap(r), [[true, false], [false]]) + assertStructurallySame(unwrap(r), [[true, false], [false]]) assertError(parse(array(array(boolean)))([[true, 42]])) }, }, record: { empty: () => { const r = parse(record(number))({}) - assertDeepEqual(unwrap(r), {}) + assertStructurallySame(unwrap(r), {}) }, ok: () => { const r = parse(record(string))({ a: 'hello', b: 'world' }) - assertDeepEqual(unwrap(r), { a: 'hello', b: 'world' }) + assertStructurallySame(unwrap(r), { a: 'hello', b: 'world' }) }, // `parse` always constructs a new record. freshRecord: () => { @@ -245,7 +225,7 @@ export const proof = { /** @type {Record} */ const out = unwrap(parse(record(number))(input)) assert(out !== /** @type {unknown} */ (input), 'expected a fresh record') - assertDeepEqual(out, { a: 1, b: 2 }) + assertStructurallySame(out, { a: 1, b: 2 }) }, error: () => { assertError(parse(record(number))({ a: 1, b: 'two' })) @@ -294,7 +274,7 @@ export const proof = { /** @type {readonly number[]} */ const out = unwrap(parse(t)([1, 2, 3])) // The const tuple `[number]` matches first and returns a length-1 result. - assertDeepEqual(out, [1]) + assertStructurallySame(out, [1]) }, }, option: { diff --git a/fjs/types/rtti/todo/proof-shared-asserts.md b/fjs/types/rtti/todo/proof-shared-asserts.md index 594041b74..c71d002b8 100644 --- a/fjs/types/rtti/todo/proof-shared-asserts.md +++ b/fjs/types/rtti/todo/proof-shared-asserts.md @@ -20,12 +20,16 @@ const assertErrorPath = (expected: readonly string[]) => } ``` -In addition, `parse/proof.f.mjs:19-27` hand-rolls an `unwrap` that duplicates +In addition, `parse/proof.f.mjs` hand-rolls an `unwrap` that duplicates `unwrap` from `fjs/types/result/module.f.mjs:59` (assert `'ok'`, return the -payload), and `assertErrorPath`/`assertDeepEqual` -(`parse/proof.f.mjs:29-59`) still use raw `if`/`throw` instead of -`assert`/`assertEq`, contrary to the proof-assertion rule in `AGENTS.md` -(each local `if`/`throw` is a permanently-uncovered branch). +payload). + +`assertDeepEqual` and `assertErrorPath`'s raw `if`/`throw` bodies are **done**: +`structurallySame` / `assertStructurallySame` landed, `assertDeepEqual` is +deleted in favour of `assertStructurallySame`, and `assertErrorPath` is now +`assertStructurallySame(e.path, expected, 'unexpected error path')`. What +remains below is the `unwrap` duplication, the `assertOk`/`assertError` move, +and sharing `assertErrorPath` itself between the two proofs. Beyond the helpers, roughly 80% of the two proof trees are copy-pasted verbatim modulo the checker name (`validate` vs `parse`): the `boolean` / @@ -33,7 +37,7 @@ verbatim modulo the checker name (`validate` vs `parse`): the `boolean` / `path` / `recursive` suites (`validate/proof.f.mjs:29-83,280-334` vs `parse/proof.f.mjs:60-113,311-362`). Only the container *success* cases legitimately differ (validate asserts identity of the returned value; parse -asserts fresh construction and dropped extras via `assertDeepEqual`). +asserts fresh construction and dropped extras via `assertStructurallySame`). ## Proposal @@ -47,9 +51,8 @@ Two steps; the first is the high-confidence part: `fjs/asserts` dependency-free). - Replace `parse/proof.f.mjs`'s local `unwrap` with `unwrap` from `fjs/types/result/module.f.mjs`. - - Rewrite `assertErrorPath` with `assertEq` (compare `e.path.length` and - each element, or compare the joined path string), export it from one - place both proofs can import — since `ValidationError` is owned by + - Export `assertErrorPath` (already rewritten on `assertStructurallySame`) + from one place both proofs can import — since `ValidationError` is owned by `validate` (parse already reuses it per `AGENTS.md`), exporting the helper from a small shared rtti proof-helper module (or from `validate/proof.f.mjs`) keeps it next to the type it inspects. @@ -67,8 +70,9 @@ Two steps; the first is the high-confidence part: - [ ] Move `assertOk`/`assertError` to `fjs/asserts/module.f.mjs` (with proof coverage) and update both rtti proofs. - [ ] Replace parse/proof's local `unwrap` with `fjs/types/result`'s `unwrap`. -- [ ] Rewrite `assertErrorPath` (and `assertDeepEqual`) on top of - `assert`/`assertEq`; share `assertErrorPath` between the two proofs. +- [x] Rewrite `assertErrorPath` and `assertDeepEqual` on top of the shared + assertion module — done via `assertStructurallySame`. +- [ ] Share the rewritten `assertErrorPath` between the two proofs. - [ ] Evaluate the `commonSuite` factory; if adopted, keep the two proof files down to their genuinely divergent cases. - [ ] Run `npx tsc` and `fjs t`. @@ -80,3 +84,6 @@ Two steps; the first is the high-confidence part: independent of it. - `AGENTS.md` proof-assertion rule — `assert`/`assertEq` over local `if`/`throw` in proof files. +- `fjs/types/object/structurally_same/README.md` — `structurallySame` / + `assertStructurallySame`, which replaced this issue's `assertDeepEqual` + subtask. From 025acec7b60efba7ff014cb408ef19a2b78e6223 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:33:46 +0000 Subject: [PATCH 2/6] CHANGELOG: entry for structurallySame / assertStructurallySame (#1538) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 431c59afd..92de2e3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ history. ## Unreleased +- `types/object`: new `structurallySame` compares two values by shape and + leaves (`Object.is` leaves, order-independent properties), and `fjs/asserts` + gains `assertStructurallySame` — the assertion to use instead of comparing + `JSON.stringify` output + [#1538](https://github.com/functionalscript/functionalscript/pull/1538) - `text/code_point`: new `eofFlush` factory builds the end-of-input step `decoder` takes. The UTF-8 and UTF-16 decoders derive their eof ops from it instead of each writing the flush out, so "leftover state becomes exactly one From cd1b73d15ccca3a7c2fd79013070ae89dd03cfc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:48:10 +0000 Subject: [PATCH 3/6] structurally_same: document that array comparison assumes dense arrays FunctionalScript cannot build a sparse array, so `Array.prototype.every` skipping holes is unreachable rather than a defect to defend against. Record that in the README and module JSDoc instead of spreading every array into a dense copy, which would cost an allocation per comparison for a value that cannot exist. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- fjs/types/object/structurally_same/README.md | 10 ++++++++++ fjs/types/object/structurally_same/module.f.mjs | 3 ++- fjs/types/object/structurally_same/proof.f.mjs | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/fjs/types/object/structurally_same/README.md b/fjs/types/object/structurally_same/README.md index c1b237d37..8545c66e5 100644 --- a/fjs/types/object/structurally_same/README.md +++ b/fjs/types/object/structurally_same/README.md @@ -41,6 +41,16 @@ There is no cycle detection: a self-referential value recurses until the stack runs out. FunctionalScript data is acyclic, so a seen-set would tax every real comparison to catch a case that cannot occur. +Array comparison likewise assumes **dense** arrays. `Array.prototype.every` +skips a sparse array's holes, so a hole-bearing first operand would compare +vacuously equal in one direction and not the other. That asymmetry is +unreachable rather than handled: FunctionalScript has no way to build a sparse +array — `new Array(n)` is not part of the language — so the input cannot occur, +and spreading every array into a dense copy to defend against it would cost an +allocation per comparison for a value that cannot exist. Callers reaching this +from plain JavaScript with a hand-built sparse array are outside the contract, +like the host objects above. + ## Why proofs should prefer it to `JSON.stringify` Proofs reached for `assertEq(JSON.stringify(a), JSON.stringify(b))` because no diff --git a/fjs/types/object/structurally_same/module.f.mjs b/fjs/types/object/structurally_same/module.f.mjs index 65256cefd..83407c294 100644 --- a/fjs/types/object/structurally_same/module.f.mjs +++ b/fjs/types/object/structurally_same/module.f.mjs @@ -26,7 +26,8 @@ const { entries, is } = Object * an object is trivially the same as itself. * - Anything else that is not a non-null object differs. * - Arrays match arrays of the same length whose elements match pairwise; an - * array never matches a non-array. + * array never matches a non-array. Arrays are assumed dense — FunctionalScript + * cannot build a sparse one; see the README. * - Other objects match when their own enumerable string properties form the * same set — order is irrelevant — and every property's value matches. A * property whose value is `undefined` is a property: `{ a: undefined }` and diff --git a/fjs/types/object/structurally_same/proof.f.mjs b/fjs/types/object/structurally_same/proof.f.mjs index 6ce8820d0..27a7ab909 100644 --- a/fjs/types/object/structurally_same/proof.f.mjs +++ b/fjs/types/object/structurally_same/proof.f.mjs @@ -42,6 +42,9 @@ export const proof = { // an array is never the same as a non-array, in either position () => differ([], {}), () => differ({}, []), + // an explicitly `undefined` element is an ordinary element + () => same([undefined], [undefined]), + () => differ([undefined], [1]), ], objects: [ () => same({}, {}), From 09f2fdf25b6c3a3a7f9802f78b77c687345102a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:33:53 +0000 Subject: [PATCH 4/6] CHANGELOG: shorten the structurallySame entry to the documented limit AGENTS.md 8.3 caps an entry at about three wrapped lines / ~250 characters. The rationale for preferring it over JSON.stringify lives in the PR and in structurally_same/README.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6b466d8..9b565cdb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,8 @@ history. ## Unreleased -- `types/object`: new `structurallySame` compares two values by shape and - leaves (`Object.is` leaves, order-independent properties), and `fjs/asserts` - gains `assertStructurallySame` — the assertion to use instead of comparing - `JSON.stringify` output +- `types/object`: new `structurallySame`, plus `assertStructurallySame` in + `fjs/asserts` — structural comparison for proofs [#1538](https://github.com/functionalscript/functionalscript/pull/1538) - `media/json/parser`: `endArray`/`endObject` no longer branch on `state.top` and `tokenToValue` drops its defensive default arm — the parser's state From 630189acaa4ad2fedf272c5c94ea0068ab6be0f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:53:33 +0000 Subject: [PATCH 5/6] structurally_same: pin the proof's object literal with @type {const} AGENTS.md 6.2 requires a const with a literal initializer to pin its type; this one relied on TypeScript's default widening. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- fjs/types/object/structurally_same/proof.f.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fjs/types/object/structurally_same/proof.f.mjs b/fjs/types/object/structurally_same/proof.f.mjs index 27a7ab909..589ba508e 100644 --- a/fjs/types/object/structurally_same/proof.f.mjs +++ b/fjs/types/object/structurally_same/proof.f.mjs @@ -29,7 +29,7 @@ export const proof = { identity: [ // the same reference short-circuits before any traversal () => { - const a = { x: [1, 2] } + const a = /** @type {const} */ ({ x: [1, 2] }) same(a, a) }, ], From 50734558b453f947fb314a913097703d7da5490f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:01:59 +0000 Subject: [PATCH 6/6] structurally_same: close three proof gaps; fix a stranded todo link Mutation testing found three mutants the co-located proof did not kill: dropping `b instanceof Array` from the array arm, dropping `bm.has(k)` from the object arm, and weakening the array length check to `>=`. Each is observably wrong on ordinary data, so add the inputs that isolate them -- an array-like with a matching `length`, two objects whose only difference is the key name with `undefined` on both sides, and a trailing `undefined` element. All three mutants now fail the proof. Deleting the todo left `fjs/media/json/todo/remove-native-json.md` pointing at a missing file; the bullet was also stale, since this change fixed the site it described. Repoint it at the new README and at the follow-up issue for the deferred audit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- fjs/media/json/todo/remove-native-json.md | 9 ++++++--- fjs/types/object/structurally_same/proof.f.mjs | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/fjs/media/json/todo/remove-native-json.md b/fjs/media/json/todo/remove-native-json.md index ed3704578..92a1664cc 100644 --- a/fjs/media/json/todo/remove-native-json.md +++ b/fjs/media/json/todo/remove-native-json.md @@ -145,9 +145,12 @@ Consider a guard so it does not come back — the cheapest is a proof in - [`fjs/fsc/todo/66c-emit-literals-via-owner-modules.md`](../../../fsc/todo/66c-emit-literals-via-owner-modules.md) — already owns the source-text-quoting sites (`fjs/types/ts`, `fjs/emergent_testing`); phase 3 defers to it rather than re-deciding. -- [`fjs/types/object/todo/structurally-same.md`](../../../types/object/todo/structurally-same.md) - — `fjs/cas/evo/proof.f.mjs:68` stringifies two values only to compare them; - `structurallySame` is the better fix for that one site. +- [`fjs/types/object/structurally_same/README.md`](../../../types/object/structurally_same/README.md) + — done: `fjs/cas/evo/proof.f.mjs` stringified two values only to compare + them, and now uses `assertStructurallySame`. The proofs that still compare a + `JSON.stringify` result against a JSON *string literal* are tracked in + [`fjs/bnf/todo/serialized-proof-expectations.md`](../../../bnf/todo/serialized-proof-expectations.md); + those are not phase-2 work either way. - [`fjs/effects/node/todo/readjsonfile-writejsonfile-helpers.md`](../../../effects/node/todo/readjsonfile-writejsonfile-helpers.md) — an on-hold design whose `writeJsonFile` half waits on phase 4. - [stringify-sorted-canonical](./stringify-sorted-canonical.md) — the key-order diff --git a/fjs/types/object/structurally_same/proof.f.mjs b/fjs/types/object/structurally_same/proof.f.mjs index 589ba508e..34d8e2b37 100644 --- a/fjs/types/object/structurally_same/proof.f.mjs +++ b/fjs/types/object/structurally_same/proof.f.mjs @@ -39,19 +39,28 @@ export const proof = { // length first, then elementwise () => differ([1], [1, 2]), () => differ([1, 2], [2, 1]), - // an array is never the same as a non-array, in either position + // an array is never the same as a non-array, in either position. + // `differ([], {})` alone does not pin this: it already fails on + // `0 === undefined`, so only an array-like with a matching `length` + // reaches the `b instanceof Array` check. () => differ([], {}), () => differ({}, []), - // an explicitly `undefined` element is an ordinary element + () => differ([1, 2], { 0: 1, 1: 2, length: 2 }), + // an explicitly `undefined` element is an ordinary element, so it + // counts towards the length () => same([undefined], [undefined]), () => differ([undefined], [1]), + () => differ([undefined], []), ], objects: [ () => same({}, {}), // property order is not part of the structure () => same({ a: 1, b: { c: 2 } }, { b: { c: 2 }, a: 1 }), - // same count, different names + // same count, different names. The first is settled by the values + // (`1` vs `undefined`); only the second — disjoint names *and* + // `undefined` on both sides — pins the key-set check itself. () => differ({ a: 1 }, { b: 1 }), + () => differ({ a: undefined }, { b: undefined }), // same names, different values () => differ({ a: 1 }, { a: 2 }), () => differ({ a: { b: 1 } }, { a: { b: 2 } }),