types/object: add structurallySame and assertStructurallySame - #1538
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 872348932e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * 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' |
There was a problem hiding this comment.
Add the new public APIs to the CHANGELOG
This code change exports structurallySame and assertStructurallySame as new public APIs, but the commit has no entry under CHANGELOG.md's Unreleased section. That omits the feature from release notes and from the changelog-driven versioning process; add the required short entry with the real PR number.
AGENTS.md reference: AGENTS.md:L1194-L1203
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already done — the entry landed in 025acec, one commit after the 8723489 this review ran against. AGENTS.md §8.3 has the entry created after the PR exists so it can cite the real number, so the gap between the two commits is expected.
Generated by Claude Code
| if (a instanceof Array) { | ||
| return b instanceof Array | ||
| && a.length === b.length | ||
| && a.every((v, i) => structurallySame(v, b[i])) |
There was a problem hiding this comment.
Visit every array index during comparison
When the first operand is sparse, Array.prototype.every skips its holes, so this equality predicate becomes asymmetric and can accept unequal arrays: structurallySame(new Array(1), [1]) returns true, while reversing the operands returns false. Iterate all indices rather than only present elements so sparse inputs cannot bypass comparisons.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
FunctionalScript doesn't allow new Array(1)
There was a problem hiding this comment.
Agreed, and thanks — I'd started down the wrong path here. I had pushed a [...a].every(...) spread plus proof cases built on new Array(1); since the language can't produce a sparse array, that was defending against an unreachable input and writing non-FunctionalScript into a proof. Both are reverted in cd1b73d — the array branch is back to plain a.every(...), and the proof covers [undefined] (an ordinary element) instead.
The asymmetry is now recorded as out of scope rather than silently dropped, in structurally_same/README.md alongside the other non-promises (host objects, cycles): sparse arrays cannot occur in FunctionalScript data, so spreading every array into a dense copy would cost an allocation per comparison for a value that cannot exist.
new Map(...) in the same module stays — that one has precedent in types/map and djs/serializer, and the lang spec uses it.
npx tsc clean, 2584 tests pass, structurally_same/module.f.mjs still 100% lines/branches.
Generated by Claude Code
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | 90c638f | Commit Preview URL Branch Preview URL |
Aug 14 2026, 05:52 AM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb5f6450b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| - `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) |
There was a problem hiding this comment.
Shorten the release note to the documented limit
This new CHANGELOG entry spans five wrapped lines and roughly 319 characters, exceeding the repository’s limit of about three lines or 250 characters for release notes. Condense it to the public API change and leave the rationale about replacing JSON.stringify in the linked PR or README.
AGENTS.md reference: AGENTS.md:L1205-L1210
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid — fixed in 09f2fdf. Now three lines / ~200 characters:
- `types/object`: new `structurallySame`, plus `assertStructurallySame` in
`fjs/asserts` — structural comparison for proofs
[#1538](https://github.com/functionalscript/functionalscript/pull/1538)
The JSON.stringify rationale is in the PR description and in structurally_same/README.md, which is where §8.3 wants it.
Heads-up on something adjacent I did not touch: the text/code_point entry from #1537, also still in ## Unreleased, has the same defect (five lines, ~290 characters). §8.3 says a feature PR touches its own entry and nothing else, so I left it — happy to shorten it in a separate commit or PR if you'd prefer it cleaned up before the next release.
Generated by Claude Code
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Reviewed at cd1b73d1 against origin/main e7e1881a.
The design is right and the implementation matches the structurally-same.md proposal
clause-for-clause — leaf placement, the asserts -> object -> nullable -> asserts cycle
avoided, the re-export from types/object, the five semantic rules, the assert* naming and
the readonly [unknown, unknown, unknown?] shape. The README is unusually good; documenting
the sparse-array asymmetry as unreachable rather than handled is exactly the right call and
exactly the right place to say it. Two things to fix.
1. Deleting the todo strands a link
bin/linkcheck.mjs goes from 137 broken links on main to 138 here, and the diff of the
broken-link sets is one line:
fjs/media/json/todo/remove-native-json.md:148:
[`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.
That bullet is now stale in substance as well as in target: this PR did fix
fjs/cas/evo/proof.f.mjs:68. It should either point at
fjs/types/object/structurally_same/README.md and be reworded to past tense, or be dropped.
fjs/bnf/todo/serialized-proof-expectations.md — the correct new home for the deferred audit
work — would be a reasonable target too.
2. Three surviving mutants in the co-located proof
I mutated structurallySame twelve ways and ran the co-located proof plus fjs/asserts/proof.f.mjs
(40 cases) against each. Nine mutants died; three survived, and each survivor is observably wrong
on ordinary FunctionalScript data, so these are genuine §3.2 gaps, not equivalent mutants:
| mutation | distinguishing input | real | mutant |
|---|---|---|---|
drop b instanceof Array from the array arm |
([1, 2], { 0: 1, 1: 2, length: 2 }) |
false |
true |
drop bm.has(k) |
({ a: undefined }, { b: undefined }) |
false |
true |
a.length === b.length → >= |
([undefined], []) |
false |
true |
Why the existing cases miss them:
differ([], {})does not exerciseb instanceof Array— it is already killed by
0 === undefined. Only an array-like object reaches the check. (differ({}, [])does pin
the otherb instanceof Array, the standalone one; that mutant died.)differ({ a: 1 }, { b: 1 })is killed by the value comparison (1vsundefined), not by
bm.has.differ({ a: undefined }, {})is killed by the count. Disjoint key names and
undefinedon both sides is the only combination that isolateshas.differ([1], [1, 2])fails>=anyway, anddiffer([1, 2], [1])is killed by the elementwise
recursion. Only a trailingundefined— the array analogue of the{ a: undefined }vs{}
case the proof already has for objects — reaches the length check on its own.
Three one-liners in the existing groups close all three:
// arrays
() => differ([1, 2], { 0: 1, 1: 2, length: 2 }), // array-like is not an array
() => differ([undefined], []), // a trailing `undefined` is an element
// objects
() => differ({ a: undefined }, { b: undefined }), // key *names*, not just countThe nine that died, for the record: Object.is → === (2 cases, NaN and -0), array length
check dropped, the standalone if (b instanceof Array), index-ordered key comparison (2 cases —
key order really is pinned), property-count check dropped, both === null guards, and both
non-recursive === substitutions for element and property values. So the coverage is good; it
is specifically the undefined-vs-missing and array-vs-array-like family that is thin — the
same family the proposal called out as must-cover.
What I checked and found clean
- No duplication.
assertDeepEqualinfjs/types/rtti/parse/proof.f.mjsis fully removed,
and grepping the tree finds no other deep/structural comparison left behind. The replacement is
strictly stronger: the old helper used===on leaves, so it acceptedNaNas unequal-to-itself
and0/-0as equal, and it threw bare strings instead of going throughassert. - §6.2
_prefix. File-aware surface diff afterprepackin both trees. The exported type
set is byte-identical tomain(non-empty output, 46,326 bytes, so the diff is meaningful) —
this PR adds no exported type at all, prefixed or otherwise. Const additions are three, all with
precise signatures and no widening toany:
assertStructurallySame: (...x: readonly [unknown, unknown, unknown?]) => void,
structurallySame: (a: unknown, b: unknown) => boolean, and the newproof. - §4 module headers survive declaration emit:
@modulepresent in
structurally_same/module.f.d.mts,types/object/module.f.d.mtsandasserts/module.f.d.mts,
and the re-export lands asexport { structurallySame } from './structurally_same/module.f.mjs'. - §8.3 CHANGELOG — entry present under Unreleased, links only
/pull/1538, no released
section touched. Pure addition, so no**BREAKING CHANGES:**prefix needed. npx tsc --noEmitexit 0;npm run prepackfrom a cleaned tree exit 0 (both passes);
npm test2584 pass / 0 fail vs 2551 onorigin/main. The +33 is exactly accounted for:
30 cases in the new proof (13 primitives, 1 identity, 8 arrays, 8 objects) plus 3 in the asserts
proof. No test lost in theassertDeepEqualremoval.
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e20a7b2155
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| identity: [ | ||
| // the same reference short-circuits before any traversal | ||
| () => { | ||
| const a = { x: [1, 2] } |
There was a problem hiding this comment.
Pin the proof's object literal type
Pin this literal with an inline /** @type {const} */ assertion. As written, TypeScript widens both the object and nested array to mutable types, so this newly authored FunctionalScript proof does not preserve the repository's immutable literal typing invariant.
AGENTS.md reference: AGENTS.md:L654-L659
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid — fixed in 630189a: const a = /** @type {const} */ ({ x: [1, 2] }).
Kept as the inline cast rather than a leading declaration annotation, per §6.2's carve-out — @type {const} above the declaration resolves const as an ordinary type name and fails with TS2304.
Checked the rest of what this PR adds: this was the only unpinned literal const. The other object/array literals in the proof are inline arguments to same/differ, not const declarations, so the rule doesn't reach them; bm in module.f.mjs is a new Map(...) call already annotated ReadonlyMap<string, unknown>, and ae is a call result, both exempt.
Generated by Claude Code
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Re-reviewed at e20a7b21 (previous review was at cd1b73d1). Baseline: origin/main = 87c58701.
The only change since the last review is the merge of main (git diff cd1b73d1 e20a7b21 touches exactly CHANGELOG.md, fjs/text/utf16/* and the todo removed by #1540). Nothing in fjs/types/object/structurally_same/ or fjs/asserts/ moved, so both open findings stand unchanged. Details below so the next round has everything in one place.
Still green
npx tsc --noEmit— exit 0.npm run prepackfrom a freshly cleaned tree — exit 0.npm test— 2586 pass / 0 fail, vs 2553 onorigin/main(+33; the new proof contributes 30 cases, consistent).- CHANGELOG entry present and correctly links only
/pull/1538.
1. Stranded link (unresolved)
Deleting fjs/types/object/todo/structurally-same.md leaves a dangling reference. Broken-link sets compared against origin/main (node bin/linkcheck.mjs, sorted, comm):
main: 137 broken PR: 138 broken
only in PR:
fjs/media/json/todo/remove-native-json.md: [`fjs/types/object/todo/structurally-same.md`](../../../types/object/todo/structurally-same.md)
only in main: (none)
One net new broken link, no pre-existing one repaired. fjs/media/json/todo/remove-native-json.md:148 needs to point at the new fjs/types/object/structurally_same/README.md (or drop the link).
2. Three surviving mutants (unresolved)
No proof cases were added, so all three still survive the co-located proof, and each is observably wrong. I rebuilt each mutant against e20a7b21 and ran the proof (30 cases) plus a direct probe:
| mutant | proof result | observable defect |
|---|---|---|
drop b instanceof Array && from the array branch |
30 pass / 0 fail (survives) | structurallySame([1, 2], { 0: 1, 1: 2, length: 2 }) → true |
drop bm.has(k) && from the object branch |
30 pass / 0 fail (survives) | structurallySame({ a: undefined }, { b: undefined }) → true |
a.length === b.length → a.length >= b.length |
30 pass / 0 fail (survives) | structurallySame([undefined], []) → true |
differ([], {}) does not catch the first because [].length === ({}).length is 0 === undefined; it takes an array-like with a matching length. differ({ a: 1 }, { b: 1 }) does not catch the second because the mismatched value is not undefined. differ([1], [1, 2]) does not catch the third because that is the short-a direction, which >= still rejects.
Three one-line additions close all three:
// arrays:
() => differ([1, 2], { 0: 1, 1: 2, length: 2 }),
() => differ([undefined], []),
// objects:
() => differ({ a: undefined }, { b: undefined }),Each of these is also documentation: the first two are exactly the "an array never matches a non-array" and "same length" clauses of the JSDoc, and the third is the "a property whose value is undefined is a property" clause in its hardest form.
Settled, not re-litigated
The implementation matches the structurally-same.md proposal clause-for-clause, assertDeepEqual is fully removed and the replacement is strictly stronger, and there are zero new exported types — all re-confirmed unchanged by the diff above.
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
|
@o2alexanderfedin both findings were right and are fixed in 5073455. Thanks especially for the mutation run — that caught real gaps, not style. 1. Stranded link
- [`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.Both targets resolve, and grepping the tree finds no other reference to the deleted file. 2. Three surviving mutantsAdded all three cases, and re-ran your mutation experiment to confirm they kill rather than merely cover — baseline 35/0, each mutant 34/1:
Your reasoning about why the existing cases missed them is now in the proof as comments, since that is the part a future reader would otherwise re-derive — e.g. that Verification
Generated by Claude Code |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Re-reviewed at 50734558 (previous review was at e20a7b21). Baseline: origin/main = 87c58701. Both open findings are resolved. Approving.
1. Stranded todo link — fixed
fjs/media/json/todo/remove-native-json.md no longer points at the deleted todo/structurally-same.md; it now links structurally_same/README.md and the new bnf/todo/serialized-proof-expectations.md, both of which exist. grep -rn 'structurally-same' fjs --include='*.md' returns nothing — no reference survives anywhere.
Broken-link sets compared against main, not counts:
PR: 155 main: 155
only in PR: (none)
only in main: (none)
The sets are byte-identical. No net-new entry, and the two links added by this PR resolve.
2. The three surviving mutants — all now die
Three proof cases were added this round, so I did not take them at face value: I reconstructed each of the three mutants against this head and re-ran the co-located proof. Baseline first, to establish the harness discriminates: 33/0 (was 30 cases, now 33).
| mutant | result | killed by |
|---|---|---|
b instanceof Array dropped from the array branch |
32 pass / 1 fail | differ([1, 2], { 0: 1, 1: 2, length: 2 }) |
bm.has(k) dropped from the property loop |
32 pass / 1 fail | differ({ a: undefined }, { b: undefined }) |
a.length === b.length → >= |
32 pass / 1 fail | differ([undefined], []) |
Each added case is the minimal one that reaches the clause, and the comments say why the obvious weaker case does not: differ([], {}) already fails on 0 === undefined and never reaches the b instanceof Array check, and differ({ a: 1 }, { b: 1 }) is settled by the values rather than by the key-set test. That reasoning is correct — I confirmed it by mutation, not by reading.
Two further mutants I built as controls also die: dropping the if (b instanceof Array) { return false } early return (1 fail) and dropping ae.length === bm.size (2 fails). Dropping the Object.is short-circuit fails 9. 12/12 of the mutants I have tried across the two rounds are now killed.
Battery
npx tsc --noEmit— exit 0.npm run prepackfrom a freshly cleaned tree — exit 0 (both passes).npm test— 2589 pass / 0 fail, against 2553 / 0 onmainat87c58701. +36, consistent with the 33 new proof cases plus theassertsproof changes.- Public surface, both axes, main vs PR. Exported type aliases: no diff at all — zero new exported types, so nothing to check against the
_convention. Exported consts: three additions and no removals or widenings:No signature collapsed to+ /fjs/asserts::assertStructurallySame: (...x: readonly [unknown, unknown, unknown?]) => void + /fjs/types/object/structurally_same::structurallySame: (a: unknown, b: unknown) => boolean + /fjs/types/object/structurally_same::proofany; noelidedin the emitted declarations. - §4 header:
@modulesurvives into the emittedmodule.f.d.mts(blank line after the header block is present). - §8.3 CHANGELOG: entry present under Unreleased, links only
/pull/1538, no released section touched. The newbnf/todo/*.mdis docs and correctly gets none.
Carried over from the last round and not re-derived: the implementation matches the structurally-same.md proposal clause-for-clause, assertDeepEqual is fully removed and the replacement strictly stronger.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90c638f7f0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (a instanceof Array) { | ||
| return b instanceof Array | ||
| && a.length === b.length | ||
| && a.every((v, i) => structurallySame(v, b[i])) |
There was a problem hiding this comment.
Handle deeply nested data without recursive calls
When two distinct but equal values contain a few thousand nested arrays, this recursive call throws RangeError: Maximum call stack size exceeded instead of returning a boolean. These are valid acyclic FunctionalScript values, and fjs/media/json/parser/proof.f.mjs explicitly verifies that the parser can produce 5,000-level nesting, so comparing two such parser results with the new assertion regresses the supported deep-data path. Use an explicit comparison worklist rather than the JavaScript call stack.
Useful? React with 👍 / 👎.
| if (a instanceof Array) { | ||
| return b instanceof Array | ||
| && a.length === b.length | ||
| && a.every((v, i) => structurallySame(v, b[i])) |
There was a problem hiding this comment.
Memoize repeated substructure comparisons
When independently constructed acyclic values share substructure, this traversal re-compares the same object pair once per incoming reference. For example, repeatedly forming a = [a, a] and an independent b = [b, b] creates only O(n) arrays but makes structurallySame(a, b) perform O(2^n) comparisons (depth 24 already takes about a second). Shared immutable data is within the documented FunctionalScript-data contract, so track previously compared object pairs to keep comparison proportional to the actual graph size.
Useful? React with 👍 / 👎.
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Re-review at 90c638f7. Previously approved at 50734558; the head has moved twice since, both times by merging main (3b739360 merged 5f5c687f/#1530, 90c638f7 merged daf48b68/#1539). Still approving — the delta is merge-only.
What actually changed since the approved head
The PR's own delta against its base is unchanged. Every source file this PR touches is byte-identical to the approved head — same blob hashes at 50734558 and 90c638f7 for all nine:
fjs/types/object/structurally_same/{module.f.mjs,proof.f.mjs,README.md}, fjs/asserts/{module.f.mjs,proof.f.mjs}, fjs/types/object/module.f.mjs, fjs/types/rtti/parse/proof.f.mjs, fjs/bnf/proof.f.mjs, fjs/cas/evo/proof.f.mjs.
git diff daf48b68 90c638f7 is the same 14-file change as before (406+/269-).
Both merges were clean apart from CHANGELOG.md, and git diff-tree --cc shows the resolution is ordering only — this PR's entry plus #1530's and #1539's entries, all three intact under ## Unreleased, nothing rewritten and no released section touched. That is the whole conflict-resolution content of both merges.
Battery re-run at the merged head
npx tsc --noEmit— exit 0.npm run prepackfrom a freshly cleaned tree — exit 0 (both passes).npm test— 2617 pass / 0 fail, againstorigin/maindaf48b68at 2581 / 0. +36, the same delta as the previous round; the merge of #1539 accounts for main's own move from 2553.- Broken-link sets vs
origin/maindaf48b68— byte-identical, empty diff over the sorted sets (not just equal counts). Nothing stranded by the merge. - Public surface, dual axis, PR vs
daf48b68afterprepackin both trees:- exported type aliases: no diff at all. Zero additions, so the
_-prefix question in §6.2 does not arise. - exported consts: exactly three additions, none widened to
any—/fjs/asserts::assertStructurallySame :: (...x: readonly [unknown, unknown, unknown?]) => void/fjs/types/object/structurally_same::structurallySame :: (a: unknown, b: unknown) => boolean/fjs/types/object/structurally_same::proof
- no deletions, so nothing that was public went away.
- exported type aliases: no diff at all. Zero additions, so the
@modulesurvives declaration emit — 1 occurrence in each ofstructurally_same/module.f.d.mtsandasserts/module.f.d.mts; zeroelidedor: anyin the emittedstructurally_samedeclaration.
Mutation coverage
The implementation and the proof are byte-identical to the head where I rebuilt the mutants, so the earlier result carries over by construction rather than by assumption. I spot-checked it anyway at this head rather than relying purely on that: dropping the bm.has(k) && guard on line 58 gives 2616 pass / 1 fail against the 2617 baseline, so differ({a: undefined}, {b: undefined}) still kills it after the merge. Mutation verified applied (grep for bm.has came back empty) and the file restored to a clean tree afterwards.
Not re-verified
I did not re-derive the clause-for-clause match against the structurally-same.md proposal, the assertDeepEqual removal, or the remove-native-json.md link retarget — those were settled at 50734558 and the files carrying them have not changed by a byte since.
Implements
fjs/types/object/todo/structurally-same.md(deleted here).Why
Proofs comparing two independently constructed values had no structural comparison to reach for, so they either hand-rolled one —
rtti/parse's privateassertDeepEqual— or comparedJSON.stringifyoutput. Serialization answers a different question: property order becomes observable,undefined-valued properties vanish,NaNand the infinities collapse tonull,-0becomes0,bigintthrows, and both sides allocate strings only to discard them.What
structurallySame(a, b)—Object.isfast path, then arrays elementwise by length, then objects on their own enumerable string properties as an order-independent set, recursing into values.It lives in a dependency-free leaf,
fjs/types/object/structurally_same/module.f.mjs.fjs/assertsneeds it, and the public object module reachesfjs/assertsthroughtypes/nullable, so implementing it in the object module would close the runtime cycleasserts -> object -> nullable -> asserts. The two consumers reach it from opposite directions:fjs/assertsimports the leaf directly,fjs/types/objectre-exports it as its public home.structurally_same/README.mdrecords this, plus what the comparison deliberately does not promise (dates, maps, sets, typed arrays, prototypes, descriptors, symbol keys, cycles) and why proofs should prefer it toJSON.stringify.assertStructurallySame(a, b, msg?)followsassertEq's shape — throws the[a, b]pair plus the optional message.Converted the sites where serialization was only a comparison mechanism:
fjs/types/rtti/parse/proof.f.mjs—assertDeepEqualdeleted (15 call sites), andassertErrorPath's hand-rolled index loop collapses to oneassertStructurallySame, removing its permanently-uncoveredif/throwbranches per AGENTS.md §3.3.fjs/cas/evo/proof.f.mjsandfjs/bnf/proof.f.mjs— the twostringify(actual)vsstringify(expected)comparisons.What I did not convert, and why
The TODO also asked for an audit of proofs comparing a computed value against a JSON string literal (~105 sites across
bnf/ll1,bnf/descent,bnf/data,djs/tokenizer). A mechanical rewrite there does not pass, and the reason is worth recording rather than working around: the BNF dispatch entries carry optional properties as present with valueundefined, whichJSON.stringifysilently drops.So
'{"":{"rangeMap":[…]}}'is a lossy projection of the real value. UnderstructurallySame,{ a: undefined }and{}deliberately differ, so the honest literal needsemptyTag: undefined/tag: undefinedon every expectation — noise that says nothing about the grammar under test. Separately,bnf/data'semptyTagMapexpectations are order-sensitive today purely because they are strings, andstructurallySameignores order by design.That is a design decision (fix the data, spell out the
undefineds, or change the helper's semantics to match how the repo already treatsStringMap), not a mechanical pass, so it is filed asfjs/bnf/todo/serialized-proof-expectations.mdwith the three options and the evidence.Verification
npx tscclean; declaration emit checked —structurallySame, the re-export, andassertStructurallySameall emit with their intended types.fjs test: 2582 pass, 0 fail.npm run cov:structurally_same/module.f.mjs,asserts/module.f.mjsandtypes/object/module.f.mjsall at 100% lines/branches/functions.Also updated
fjs/types/rtti/todo/proof-shared-asserts.md, which tracked theassertDeepEqual/assertErrorPathrewrite as a subtask — marked done, remainingunwrapandassertOk/assertErrorwork preserved.🤖 Generated with Claude Code
https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
Generated by Claude Code