#3273

Show a converted record's own values after it is written

Opencramforcewants to merge intofrom

Object.assign(element, source) left a field stale when element came out of an array that a generic helper had returned:

const messages: Message[] = id([{ content: [{ type: "toolCall", id: "a", name: "first", args: { k: "v" } }] }]);
const element = messages[0]?.content[0];
if (element?.type === "toolCall") {
  Object.assign(element, { type: "toolCall", id: "a2", name: "third", args: { k: "v2" } });
  console.log(JSON.stringify(element)); // Node: "args":{"k":"v2"}   before: "args":{"k":"v"}
}

The write happened; the read was wrong. The literal is typed by its own shape (args: { k: string }, plus the text?: undefined the checker adds when it normalizes the array's element union), and converting it to Message[] builds a copy that links to its source so that Object.keys, JSON, inspection and the other reflective views can show the source's other properties (record-links.ts). The compose helper took the copy's value for a field both types declare only when the two types were equal; for args (a fixed record in the source, an index-signature record in the copy) and note (string in the source, string | undefined in the copy) it read the source. So any later write to such a field, by Object.assign, by assignment, or into the object the field holds (element.args.k = "x"), was invisible to every reflective view. The same happened with no Object.assign at all.

The composed record now has the copy's type for each field the two declare differently (IrRecordLink.composed; absent, and the IR unchanged, when no field differs), so it holds the copy's live value for every shared field and the source's value only for the fields the copy lacks. A conversion that changes a field's type from a source that itself carries other properties (a kept checked source, a link of its own, or a per-value key order) is refused by name (SC1090), because the composed type has no helpers that answer for those.

The composed record enumerates as its source does: a shape interned for it takes the source's key order, and when the program already has a type with exactly the composed fields in another order, the conversion is refused by name as well, since that type's values keep their own order (second commit, from review).

Decision recorded in DECISIONS.md (2026-11-07, metamorphic-ices): the alternative, refusing writes to such fields, cannot see the nested write and would have had to refuse the conversion itself.

Tests

  • tests/corpus/object-assign-converted-record.ts: Object.assign with an index-signature field and an explicit undefined, a nested write, a reassignment, and each view (JSON, Object.keys, Object.entries, console.log). Fails without the change.
  • packages/compiler/test/lowering-links-order.test.ts: the key-order refusal, and the two neighbouring programs that compile.
  • packages/compiler/src/ir/function-references.test.ts: the new field is classified.
  • packages/compiler/src/ir/codec.generated.ts regenerated (pnpm generate:ir-codec).

Proof

Zero-cost gate (bin/validate … --skip=lanes,bench,t262,san-bootstrap --gate-corpus=all, against main dfa7753d3) with an allow file naming four programs and --gate-max-size-growth=1: PASS. 5243 programs identical, 103 fail to build on both sides, 4 allowed, no runtime-only rows; self compile time -0.25% [95% CI -1.21%, +0.28%]. The four:

  • corpus/wider-record-sources.ts (+288 bytes) and corpus/6495-key-order-rewritten-literals.ts (+2912 bytes, 0.69%): each converts a record into a type that declares a shared field at another type. Their differential output is unchanged.
  • self/cli and fixture/llvm-emitter: the compiler's own source has two such conversions, backend/llvm/record-results.ts:222 and ir/literal-tables.ts:309, which build IR statements as object literals in a map callback and convert them to IrStmt, whose obj and value are the IrExpr union where the literal has one record arm. 4 of the emitter's 100 link pairs get a composed type. The copy holds the same reference the source does until it is written, and the native bootstrap's comparison of the Node-built and the natively built compiler's IR and LLVM passes.

The second commit (the key-order guard) came after this round: it adds a refusal and changes no accepted program; the three corpus programs with a composed type still match Node and the compiler still compiles itself with it.

Plain and sanitized native bootstraps (tests/harness/self-hosting-native-driver.test.ts, 32 vCPU / 64 GB, Intel Xeon 2.90 GHz) pass on this change together with the four sibling changes from the same task (record links, JSON and inspection, Object.create, builtin function identity), on main dfa7753d3: 1813 s plain, 2890 s sanitized. On the same combination the whole differential corpus (5258 programs), the compiler unit tests (4925) and the diagnostics snapshots (301) pass.

Re-running the six rewrites over the same 318 programs with all five changes on that main: 0 internal compiler errors (17 before), 4 silent variants in 1 program (14 in 5 before; what remains is the key-order case, which other work owns), 65 variants in 22 programs that fail at run time (84 in 24 before, measured on a main 160 commits older).

🤖 Generated with Claude Code

Activity

10
vercel[bot]6d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 1:23pm UTC
vercel[bot]reviewed6d ago
cramforcereviewed6d ago
cramforce5d ago

Merged main into the branch (895e57f12, then 50bcfe1b3 without conflicts) and fixed what the merge broke: 4c6d9b5d0. It also merges cleanly with main as of 97d372266.

What conflicted: packages/compiler/src/ir/codec.generated.ts, the layout digest line. Both sides changed ir.ts, so the file is main's, regenerated from the merged ir.ts with pnpm generate:ir-codec (a second regeneration changes nothing, also after the second merge).

What the merge broke: the compiler refused its own source. The text merged cleanly and both native bootstraps failed in the seed build:

packages/compiler/src/frontend/lowering/class-instance-constructor.ts:488:9 - error SC1090: converting an object that carries other properties to a type that declares 'value' differently is not supported yet

That is this PR's refusal, on code main gained after the PR was written (#3165): an immediately called arrow with no declared result returns { has: { kind: "ternary", … }, read }, so its literals are typed by their own shapes and converted to { has: IrExpr; read: () => IrExpr } afterwards, and one of those shapes carries other properties. The arrow now declares its result (8995cb530), which is what the diagnostic's hint asks for; the literals are built as IrExpr. It was the only refused site, and after the second merge (1,860 more lines of compiler source) there is still none. The choice (change the source, keep the refusal) is recorded in the fleet's decision log with the alternatives; the short version: narrowing the refusal brings back the stale read it exists for, since a nested write cannot be seen at the conversion.

Two consequences worth knowing before merging:

  • The refusal reaches further than it did. More shapes carry other properties on today's main (per-value key order). No corpus program and no npm-suite program is newly refused (gate and suite below), but a program of this form that main compiles is refused by name with this PR.
  • The zero-cost gate reports ERROR self/cli for this PR until it merges: the gate's self program is the baseline's compiler source, which still has the undeclared arrow. With the programs taken from this branch (--programs=candidate) the same gate passes.

Proof on the merged head (Vercel Sandboxes, 32 vCPU / 64 GB, Intel Xeon @ 2.90GHz unless noted; gate, bootstraps and the suites on 8995cb530 over main 895e57f12; the second merge re-ran the build, lint, format, the TS7 baseline check, the self-compile and 409 focused tests):

  • Native bootstraps (tests/harness/self-hosting-native-driver.test.ts): plain passes (1,670 s), SCRIPTC_SAN=1 passes (2,555 s). Before the source fix both failed after 541 s and 553 s with the error above.
  • Zero-cost gate, --gate-corpus=all, --gate-max-size-growth=1, the PR's allow file plus one line: 5,290 identical, 107 fail to build on both sides, no runtime-only row, 4 allowed, and self/cli as said above. The allowed programs: corpus/wider-record-sources.ts, corpus/6495-key-order-rewritten-literals.ts, fixture/llvm-emitter, and one that is new: corpus/in-union-arm-kept-source.ts (+376 B, +0.18%), which main added in #3238 and which converts a Tagged into a Reply that declares handled at another type, so it is this PR's third corpus program of that kind. Its output matches Node, plain and sanitized.
  • self/cli with this branch's source (scripts/zero-cost-gate.mjs --programs=candidate --set=self): allowed, PASS; compile time +0.84% [95% CI +0.61%, +1.06%] over two pairs, within tolerance.
  • Fixture compile time. The driver's round read +3.44% [+2.34%, +4.54%] from two pairs per fixture in a shared pool and called it slower. Six pairs per fixture alone on one machine (Intel Xeon @ 2.50GHz): +0.11% [-0.41%, +1.72%], within tolerance (llvm-emitter -1.32% to +1.96%, llvm-layouts, whose output is identical, -0.82% to +4.28%). Benchmarks' compiles: -0.23% [-1.00%, +0.95%].
  • npm suite (tests/harness/npm.test.ts, npm-shape-overlay.test.ts, with a libnode pack): 306 tests pass.
  • lowering-links-order, function-references, codec (53 tests) and the record-conversion programs of differential and llvm-differential (216 cases, with object-assign-converted-record), plain and sanitized; pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only: clean.

Not run on this head: the full plain and sanitized lanes, the benchmarks' run time, and the 318-program metamorphic rewrite from the description.

One red check to expect that is main's: packages/compiler/src/executable/executable-cache.test.ts, "every build and run option is in the cache keys or says why it need not be" (--emit-lowered, --from-lowered, --from-lowered-accept have no rule; #3218 and #3260 merged without each other's lines).

cramforce5d ago

A rework of this PR exists as two signed commits on top of this head, local and unpushed (rework/metamorphic-record-links). The refusal for a source that carries other properties is gone: the composed record's type now answers for what its source carries (a kept checked source, a value converted twice, several key orders), so tests/corpus/8962-asserted-subtype-record-beside-undefined.ts compiles and matches Node. Of 462 generated programs around the shape, main matches Node in 252 and is silently wrong in 210; this head refuses 324 of them, 144 of which are correct on main; the rework matches Node in all 462. It lands with a lang train's rollup PR, which supersedes this one.