#3214

Walk a linked copy's unknown members with the runtime JSON traversal

Draftcramforcewants to merge intofrom

Stacked on #3188 (functions under JSON.stringify): its commits are in this branch, and 250511ea1 is this change. Draft until #3188 merges.

What this fixes

JSON.stringify of a narrowed copy writes the source object, since Node keeps the one object. The typed writer serialized the source type's unknown members itself, and for a record member that is not what JSON.stringify does:

interface Wide { id: number; extra: unknown; more?: unknown }
interface Narrow { id: number }
function narrow(wide: Wide): Narrow { return wide; }

JSON.stringify(narrow({ id: 1, extra: undefined }));
// Node {"id":1}            main {"id":1,"extra":null}
JSON.stringify(narrow({ id: 3, extra: { toJSON() { return 5; } } }));
// Node {"id":3,"extra":5}  main {"id":3,"extra":{}}

A value of the wide type itself was always right: the frontend sends a record with an unknown member to the runtime JSON traversal. Only the link path reached the typed writer with such a type, because links are not known when the call is lowered.

What changes

#3188 already decides, when links are final, whether a call whose type reaches a kept link stays with the typed writer, takes the runtime traversal, or is refused. It kept one special case so that no existing program changed: below a link source, an unknown member counted as something the typed writer serializes. This PR deletes that case (JsonLinks.within in ir.ts), so the predicate has one rule for unknown again and such a call takes the runtime traversal.

Zero cost: two programs differ, by design

Gate over the benchmarks, the self-hosted compiler and the whole corpus, 250511ea1 against a09254dd3, with --gate-allow='JSON\.stringify' and --gate-complete: PASS, 5,212 identical, 97 fail to build on both sides, 2 allowed. Compile time within tolerance (self +0.41%, benchmarks -0.01%, fixtures +0.53%). The two allowed rows are the only programs that are not identical.

  • corpus/7990-inferred-return-overlapping-records.ts: its JSON.stringify(outer(…)) calls serialize copies whose source has an unknown member. Output is unchanged and matches Node (plain and sanitized); the executable is 0.66% smaller.
  • self/cli: one call in the compiler's own source, packages/compiler/src/backend/runtime-pack-native.ts:183 (store.write(key, JSON.stringify(entry)), the stored runtime-pack selection, whose artifact records come from a manifest type with when: unknown). It is the only call the rewrite touches in the self build (listed by a probe on the candidate). The native bootstrap, in which the natively built compiler rebuilds itself and runs the bootstrap contracts, is the check on the self build: plain and sanitized pass at 250511ea1 (32 vCPU / 64 GB). The branch has since merged #3188's third commit, which only changes which calls are refused.

Tests

  • tests/corpus/json-stringify-linked-copy-unknown.ts: undefined, nested functions, a member's toJSON, indents, arrays of copies, a source with its own toJSON, and a fresh record of the narrow type. Matches Node, plain and sanitized.
  • packages/compiler/src/ir/ir.test.ts: a link source with an unknown member selects the runtime traversal.
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, the diagnostics corpus and the unit tests pass (343 tests).

🤖 Generated with Claude Code