Skip to content

types/object: add structurallySame and assertStructurallySame - #1538

Merged
sergey-shandar merged 10 commits into
mainfrom
claude/todo-implementation-rifq4g
Aug 14, 2026
Merged

types/object: add structurallySame and assertStructurallySame#1538
sergey-shandar merged 10 commits into
mainfrom
claude/todo-implementation-rifq4g

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

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 private assertDeepEqual — or compared JSON.stringify output. Serialization answers a different question: 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 discard them.

What

structurallySame(a, b)Object.is fast 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/asserts needs it, and the public object module reaches fjs/asserts through types/nullable, so implementing it in the object module would close the runtime cycle asserts -> object -> nullable -> asserts. The two consumers reach it from opposite directions: fjs/asserts imports the leaf directly, fjs/types/object re-exports it as its public home. structurally_same/README.md records 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 to JSON.stringify.

assertStructurallySame(a, b, msg?) follows assertEq'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.mjsassertDeepEqual deleted (15 call sites), and assertErrorPath's hand-rolled index loop collapses to one assertStructurallySame, removing its permanently-uncovered if/throw branches per AGENTS.md §3.3.
  • fjs/cas/evo/proof.f.mjs and fjs/bnf/proof.f.mjs — the two stringify(actual) vs stringify(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 value undefined, which JSON.stringify silently drops.

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, so the honest literal needs emptyTag: undefined / tag: undefined on every expectation — noise that says nothing about the grammar under test. Separately, bnf/data's emptyTagMap expectations are order-sensitive today purely because they are strings, and structurallySame ignores 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 treats StringMap), not a mechanical pass, so it is filed as fjs/bnf/todo/serialized-proof-expectations.md with the three options and the evidence.

Verification

  • npx tsc clean; declaration emit checked — structurallySame, the re-export, and assertStructurallySame all emit with their intended types.
  • fjs test: 2582 pass, 0 fail.
  • npm run cov: structurally_same/module.f.mjs, asserts/module.f.mjs and types/object/module.f.mjs all at 100% lines/branches/functions.
  • No import cycle: the leaf imports nothing.

Also updated fjs/types/rtti/todo/proof-shared-asserts.md, which tracked the assertDeepEqual/assertErrorPath rewrite as a subtask — marked done, remaining unwrap and assertOk/assertError work preserved.

🤖 Generated with Claude Code

https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs


Generated by Claude Code

claude added 2 commits August 13, 2026 22:32
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FunctionalScript doesn't allow new Array(1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread CHANGELOG.md Outdated
Comment on lines +23 to +27
- `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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 exercise b instanceof Array — it is already killed by
    0 === undefined. Only an array-like object reaches the check. (differ({}, []) does pin
    the other b instanceof Array, the standalone one; that mutant died.)
  • differ({ a: 1 }, { b: 1 }) is killed by the value comparison (1 vs undefined), not by
    bm.has. differ({ a: undefined }, {}) is killed by the count. Disjoint key names and
    undefined on both sides is the only combination that isolates has.
  • differ([1], [1, 2]) fails >= anyway, and differ([1, 2], [1]) is killed by the elementwise
    recursion. Only a trailing undefined — 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 count

The 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. assertDeepEqual in fjs/types/rtti/parse/proof.f.mjs is 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 accepted NaN as unequal-to-itself
    and 0/-0 as equal, and it threw bare strings instead of going through assert.
  • §6.2 _ prefix. File-aware surface diff after prepack in both trees. The exported type
    set is byte-identical to main (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 to any:
    assertStructurallySame: (...x: readonly [unknown, unknown, unknown?]) => void,
    structurallySame: (a: unknown, b: unknown) => boolean, and the new proof.
  • §4 module headers survive declaration emit: @module present in
    structurally_same/module.f.d.mts, types/object/module.f.d.mts and asserts/module.f.d.mts,
    and the re-export lands as export { 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 --noEmit exit 0; npm run prepack from a cleaned tree exit 0 (both passes);
    npm test 2584 pass / 0 fail vs 2551 on origin/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 the assertDeepEqual removal.

claude and others added 2 commits August 14, 2026 03:33
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 prepack from a freshly cleaned tree — exit 0.
  • npm test — 2586 pass / 0 fail, vs 2553 on origin/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.lengtha.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

Copy link
Copy Markdown
Contributor Author

@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/media/json/todo/remove-native-json.md:148 repointed. You were right that it was stale in substance too, so it is now past tense and names both successors:

- [`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 mutants

Added all three cases, and re-ran your mutation experiment to confirm they kill rather than merely cover — baseline 35/0, each mutant 34/1:

mutant proof before proof after
drop b instanceof Array && 30 pass / 0 fail 34 pass / 1 fail
drop bm.has(k) && 30 pass / 0 fail 34 pass / 1 fail
a.length === b.length>= 30 pass / 0 fail 34 pass / 1 fail

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 differ([], {}) is settled by 0 === undefined before b instanceof Array is ever consulted, so only an array-like with a matching length reaches it.

Verification

npx tsc clean, fjs test 2589 pass / 0 fail (+3, matching the three new cases), structurally_same/module.f.mjs still 100% lines/branches/functions.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 prepack from a freshly cleaned tree — exit 0 (both passes).
  • npm test2589 pass / 0 fail, against 2553 / 0 on main at 87c58701. +36, consistent with the 33 new proof cases plus the asserts proof 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:
    + /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 signature collapsed to any; no elided in the emitted declarations.
  • §4 header: @module survives into the emitted module.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 new bnf/todo/*.md is 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.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
@sergey-shandar
sergey-shandar removed this pull request from the merge queue due to a manual request Aug 14, 2026
@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Merged via the queue into main with commit 699c960 Aug 14, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/todo-implementation-rifq4g branch August 14, 2026 05:57

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 prepack from a freshly cleaned tree — exit 0 (both passes).
  • npm test2617 pass / 0 fail, against origin/main daf48b68 at 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/main daf48b68byte-identical, empty diff over the sorted sets (not just equal counts). Nothing stranded by the merge.
  • Public surface, dual axis, PR vs daf48b68 after prepack in 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.
  • @module survives declaration emit — 1 occurrence in each of structurally_same/module.f.d.mts and asserts/module.f.d.mts; zero elided or : any in the emitted structurally_same declaration.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants