Skip to content

Train lang 3: a function is one value with its own properties, kept keys through record assertions, untyped calls through typed slots, generator arrays refused beside an island - #3405

Merged
cramforce merged 49 commits into
mainfrom
train/lang-3
Nov 8, 2026

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Four language fixes validated together in one round and landed as one branch: each was proven by its owner on its own head; this is the four merged on main, with their conflicts resolved once. The round ran on top of train lang-2 before it landed (with the landing rollup, #3395); the checks on the tree merged with today's main are at the end.

What lands

  • Supersedes Admit an own property for every function member, in a runtime build #3053 (with Store function+namespace merges and mock functions in the callable table #3076 and Read a function's inherited call, bind and toString as values #3157, which were merged into its branch, and its owner's review fix on top of the PR's head): a function is one value with its own properties. fn.call = f, Object.defineProperty(fn, "bind", …) and delete fn.apply create, define and delete an own property as in Node (main refused the write, SC1090); a function merged with a namespace (function fmt() {} and namespace fmt { export const digits = 2 }) and a mock.fn() keep their members on the function itself, so Object.keys(fmt), "digits" in fmt and a member replaced with Object.assign answer as Node does; fn.call, fn.bind and fn.toString read as values; an import x = ns.member alias of a merge member takes its snapshot. A program that never writes one of a function's inherited member names links the unchanged runtime objects: the paths live in a per-program build (SCR_FN_MEMBER_PROPS) of scr_json.c and scr_dyn_invoke.c.
  • Supersedes Carry kept-source keys through record width copies #2999: an assertion between two record types keeps the keys the object carries. const narrow = JSON.parse(text) as Base; const wide = narrow as unknown as Wide; wide.extra: Node reads the value, main read undefined. The width copy reads a field the source type does not declare from the kept source of a checked value, with its own check; a program that never converts a checked value into the source type keeps its copy byte for byte.
  • fix/untyped-call-through-slot (no PR; it changes Give a function every argument of the final call, whatever slots it passed through #3263's function views, which train lang-2 landed): an untyped call reaches the function behind a typed slot. A function declared with an any parameter, stored in a slot whose function type names a record with required members and called back without that type, threw TypeError: expected string at $.diff, got undefined before it ran, because the argument was converted into the slot's parameter type first. Node runs the function. The call is now checked against the function that runs; a call through the slot's own type is checked as before.
  • Supersedes Keep arrays of generators refused beside a Node island, and their identity without one #3366: arrays that hold generators are refused by name again in a program that runs a Node island (eleven observations of the island value matrix were silent wrong answers on main since Refuse or lower five shapes that crashed the compiler #3269), and without an island a generator read back out of an array is the same object.

Not in this train: the compiler fixes found on the Vercel CLI (they land through train cli-1).

Conflicts and fix-ups on the train

Validation

All in Vercel Sandboxes. One full round on the train (c5f1073e18: train lang-2's head 5b32646f38, which contains main 851b59b90f, plus the four members), compared with lang-2's head, so every row below is this train's.

  • Type check: pnpm -r build passes.
  • Lanes: plain 24,246 passed, none failed; sanitized 20,888 passed, 1 failed (98 jobs). The one sanitized failure is packages/cli/test/mcp.test.ts, "SIGTERM stops running programs before the server exits" ("condition not met in time"), a timing test of the CLI's MCP server on a loaded lane box: no member changes a file under packages/cli, and the file alone, sanitized, passes 8 of 8 on the same tree.
  • Native bootstraps: both pass. Job times, Sandbox start included: plain commands 22.2 min, rebuild 35.5 min; sanitized commands 45.8 min, rebuild-emit 50.7 min, rebuild-frontend 54.6 min.
  • Zero-cost gate, --gate-corpus=all: PASS. 5,880 programs: 5,509 identical, 288 fail to build on both sides (the island programs, built without a libnode pack on both sides), 81 allowed and every one a member's row, 2 of the train's new programs that the base cannot build (Admit an own property for every function member, in a runtime build #3053's), no runtime-only row.
  • Compile time: self-compile +0.97% [95% CI +0.53%, +1.27%] and fixtures +0.59% [+0.29%, +0.88%], within tolerance; benchmarks +1.04% [-0.58%, +2.31%], which the gate calls inconclusive. The train adds 0.3% to the compiler's own source; compiling that source gives 0.2% more LLVM text and 0.3% more functions.
  • bench-runtime, 71 workloads: geomean +0.4%; 68 neutral, 1 faster (union-node-sets -9.0%), 2 reported slower (regex-logs +15.7% [+6.7%, +44.2%], atomics-contended +5.3% [+0.0%, +7.8%]). Both of those executables are byte-identical to the base's in the gate, so the two readings are the machine, not the train.
  • Test262 (Function, JSON, Object, Promise, Proxy, Reflect; 10,396 variants): 0 regressed, 0 gained.
  • Focused, on the same tree (Intel Xeon 2.90GHz, 8 vCPU): ir/codec.generated.ts regenerated and committed, no other generated file changes (generate:ir-fields, manifest, node-compat --local, gen-builtin-object-members --check, runtime-refusals --write); pnpm lint (393 runtime refusal sites, none missing), format:check, node-compat:check, the ts7 baselines; 27 guard test files, 260 tests; the members' corpus programs with their neighbours (namespaces, mocks, callables, function views, main's variadic programs), differential.test.ts and llvm-differential.test.ts 244 of 244 plain, differential.test.ts 122 of 122 sanitized; 55 unit and harness test files with the diagnostics snapshots, dyncheck, npm-static and the island harness, 1,111 tests; the diagnostics snapshots sanitized, 370 of 370; scripts/checker-identity.mjs for the untyped-call branch's four programs; the island value matrix against the committed table, 239 of 239.
  • The variadic matrix of Train lang 2: one identity for empty arrays, every argument through function slots, copy warnings from the lowered program, holed arrays in generic loops, typed promise resolution, built-in member writes refused where read natively #3368 (9 function kinds by 10 callers, 90 programs under Node and compiled): 21 same, 64 refused at build, 3 loud, 2 silent. The untyped-call branch closes three of the five cells that are silent on main (an untyped call of a stored function with optional parameters, and of a typed rest function held by a constant or declared); the two left are silent on main too (a function expression that reads arguments, called through an untyped value or through apply).

The 81 rows, by member

The pushed head (aa4b28b88c = the train merged with main cfe2e1b6ba, which has the landing rollup)

  • Merge: eight files conflicted. Five are "both rows" (main's moduleCompileCache beside Admit an own property for every function member, in a runtime build #3053's fnMemberProps); ir/builtin-never-throw.ts (main's two rows, then Carry kept-source keys through record width copies #2999's); lower-modules.ts (Admit an own property for every function member, in a runtime build #3053's store of a hoisted function member, then main's closed-island block; both stay); ir/codec.generated.ts, regenerated on the merged sources.
  • On that tree (Intel Xeon 2.90GHz, 8 vCPU): the build (the type check); the codec, field tables, manifest, node-compat tables and the runtime refusal baseline regenerate to the committed files; pnpm lint (393 refusal sites, none missing), format:check, node-compat:check, the ts7 baselines; 27 guard test files, 260 tests; the members' corpus programs, 244 of 244 plain and 122 of 122 sanitized; 55 unit and harness test files, 1,119 tests; the diagnostics snapshots sanitized, 375 of 375; checker identity for the untyped-call programs; the island value matrix against main's table, 239 of 239; the variadic matrix as above (21 same, 64 refused, 3 loud, 2 silent).
  • Zero-cost gate on that tree against main cfe2e1b6ba, --gate-corpus=all (gate-only round, identity verdict; its self-compile timing follows as a comment): 5,528 identical, 315 fail to build on both sides, nothing that differs outside the allow list.
  • The full round (lanes, both bootstraps, bench-runtime, Test262) ran on the tree before this merge, above; CI runs the lanes and the bootstraps on the merge.

Not run

The macOS lanes; the Node island conformance lane (no member touches island code beyond #3366's refusals; the island value matrix ran, above); a pi or Vercel CLI build (the untyped-call branch was measured against pi's repro by its owner).

🤖 Generated with Claude Code

cramforce and others added 30 commits November 4, 2026 21:08
An `as unknown as T` between two record types lowers to a width copy that
reads only the declared fields of the source type. Node erases the
assertion, so a target field the source type does not declare still answers
the key the object carries; the copy answered undefined instead
(SPEC.md finding K1).

A record a checked conversion built keeps its undeclared keys beside it.
recordWidthHelper now registers each target field the plan leaves absent,
and finalizeRecordLinks rewrites that field's value in the projection to
read the name off the source record's live checked view when the record has
one, and to keep the absent value otherwise. The rewrite is gated on the
finalize scan's sourced shapes, so a program that never converts a kept
source into the source type keeps its projection and IR byte for byte.
Fields outside the dynCheck domain keep their absent value rather than emit
an invalid check; a kept value of the wrong type throws the catchable
TypeError (types don't lie).

Corpus program 8978 covers the repro, a function parameter and return, a
nested field, a union-typed variable and a null-armed target, a union field,
an untyped field, an absent key, a two-step copy, a plain record, and
arrays, all differential against Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The width copy that carries undeclared keys has to answer an undeclared
key from the record's kept source. The first form built the record's
capsule and read it with the dynamic key cache; both reach the dynamic
tier, so a program that only projected a record through `as unknown as T`
linked the tier's key-get, construct and proxy-get units and its
executable grew by a third.

Answer the key from the kept source's own view instead: the new runtime
entry scr_record_source_get reads the key as the view's own property
(scr_dyn_obj_read), so a data property answers its value and an accessor
runs with the view as its receiver, as it does for every other observer
of the kept source. The view holds the source object's own properties and
links no prototype, so a key the source lacks answers the undefined
singleton, which the field's own absence then decides. The read never
builds the record's capsule, and the lowering calls it through
record.sourceGet.

The entry point is a unit of its own (scr_record_source_get.c) rather
than an addition to scr_json.c, and its header is not listed in
scr_runtime.h: the compiler names the symbol directly, so no other unit's
preprocessed text changes, every default object in the runtime pack stays
byte-identical, and a program that never projects a record links nothing
new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A function may carry an own data property of any name, the inherited Function.prototype and Object.prototype members included: `fn.call = f` shadows the builtin exactly as JS says, and Node reads the own property at a call site. The typed lowering now stores those names in the closure's own-property table, and the runtime reads the table first — in a build of scr_json.c and scr_dyn_invoke.c selected only for the programs that write, define or delete one of those names. Every other program links the unchanged objects, byte for byte.

- runtime-features.ts: a `fnMemberProps` runtime feature answers true when a function spells one of the nine inherited member names as the key of a `dyn.keySet`/`keySetComputed`/`keyDelete`/`keyDeleteComputed`, or as the key of a `dyn.defineProperty`/`definePropertyShapes` on a boxed callable. The key is followed through the hidden local a keyed write evaluates it into (lower-exprs.ts) and the box a string key takes, in one forward pass with an early exit, and per function because a hidden key local is function-scope: the same `%setKey.0` names a different value in every function. A definition counts only on a boxed callable, since class and object lowering define `constructor` and `toString` on ordinary objects that this path never sees. `Object.assign(fn, {…})` lowers to `dyn.assignAll` and is not examined, so such a program keeps the default refusal main gives it.
- runtime-pack-matrix.mjs: scr_json.c gains a `fn-member-props` variant and a `node-island-fn-member-props` build that pairs it with the node-island build, and scr_dyn_invoke.c gains `fn-member-props`; the JS-object-model and worker twins follow from the existing clones. backend/native/executable.ts emits `SCR_FN_MEMBER_PROPS` only for a program the rule marks, so the source build names the same objects the matrix selects.
- scr_json.c: +51 lines, nothing removed. Both behaviour changes sit behind `#ifdef SCR_FN_MEMBER_PROPS`, so the default build's preprocessed source is token-identical to main and a program that does not use the feature links the unchanged object. In the feature build `scr_dyn_fn_property_fence` refuses only `caller`, `arguments` and `__proto__` — Node answers those through the builtin accessor (a TypeError) and the prototype setter, and neither is lowered on a function yet, so refusing them beats inventing an own data property Node would not create — while `defineProperty`, `keySet` and `keyDelete` store, overwrite and delete the table entry like any other key, and `scr_dyn_value_of` calls an own `valueOf` before answering the receiver.
- scr_dyn_invoke.c: +29 lines, nothing removed, also gated. The checked-dynamic call path reads an own call/apply/bind when the closure carries a table, before the native call/apply/bind lowering; a function that never received a property pays one pointer test.
- callable-properties.ts: the write target admits an inherited name without a user declaration (the write IS the own property JS creates), while a read still asks for one, so a plain `fn.call` keeps the protocol lowering it always had. `FUNCTION_INHERITED_MEMBERS` gains `toString`.
- lower-expando.ts: the SC1090 refusal for those nine names and its member set are deleted; the read-only length/name/prototype/caller/arguments fence stays.

Zero cost. The gate against 915aaa1 compares 5,216 corpus programs: 5,105 byte-identical, 95 that fail to compile the same way on both sides, and 16 differences — each one a program that writes the feature's names, with byte-identical IR and LLVM and only the linked runtime objects changed:

  corpus/7037-js-var-open-writes/main.ts, 7040-es5-error-constructor.mjs, 7045-instanceof-constructor-values.mjs, 7753-typed-array-named-properties.js, 8110-builtin-prototype-own-members.js, 8260-class-prototype-views.mjs, 8321-typed-array-constructor-kind.js, 8702-js-primitive-object-coercion.js, 8963-js-boolean-method-receiver.js, 8973-js-boolean-prototype-method-values.js, bound-constructor-inspect-names.cjs, checked-addition.js, es5-constructor-inspect-names.cjs, js-callable-proxy.js, js-class-member-dispatch.js, js-primitive-wrappers.js

Fifteen match the change's signature in `--allow`; checked-addition.js deletes `object.valueOf`, which the signature's `delete` alternative covers. The two self-hosting fixtures and the self program are identical. The dynamic-tier symbol check passes with `scripts/zero-cost-gate.mjs` untouched: the script is byte-identical to main. Timings: bench geomean +0.26% [-0.58%, +1.03%] and fixtures +0.52% [-0.28%, +1.31%], both within tolerance; self -1.07% [-1.69%, +2.27%], inconclusive, which the gate reports as a spread rather than a regression.

Origin/main also changed lower-stmts.ts in this round; the branch rebases onto it cleanly, and no other file the branch touches moved on main.

Tests: corpus 9224-callable-protocol-members (typed shadowing, `Object.assign`, `delete`, keys, typeof) and 9225-dynamic-fn-protocol-shadow (checked-dynamic apply/call/bind dispatch, `defineProperty`, `delete`) run under Node and as compiled binaries and compare byte for byte. runtime-features.test.ts pins the rule, including the per-function scoping case and the keys it must not match; runtime-pack-matrix.test.ts pins the variant selection for both units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`f.apply(thisArg, tuple)` on a callable whose own type declares `apply`
resolves statically to a direct call of the function body: the wrapper
that the protocol fast path folds away. For a value with an own `apply`
table entry that is the wrong callee — Node reads the table and calls
that entry — and the general member-call path, which does read the table,
cannot lower the argument list here: a forwarded tuple has no value form
(its positions are the caller's native slots). The combination now refuses
by name with SC1090 instead of running the wrong callee.

- callable-properties.ts: `callableDeclaresMember` answers whether the
  value's own type declares `name` as a user member, so a statically
  resolved protocol form can ask before it takes its shortcut.
- typed-arguments.ts: `lowerForwardingApply` asks it for `apply` after the
  forwarded-tuple guard. An array argument is a value and keeps working
  through the general member-call path.
- tests/corpus/9226-callable-own-apply-dispatch.ts: the argument forms that
  do have a value (array literal, tuple, plain array) dispatch to the own
  entry, a replaced entry is followed, and a plain call never reads the
  table. Byte-identical to Node.
- tests/diagnostics/callable-own-apply-forwarded-tuple.ts: the refusal,
  with its snapshot and order-parity baselines.

No program that compiled on main stops compiling: main refused the write
that creates the own `apply` in the first place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `function fmt() {}` merged with `namespace fmt { export const digits = 2 }`
is ONE value in Node: the transform assigns each exported member onto the
existing function object, so `Object.keys(fmt)` lists them, `"digits" in fmt`
answers true, inspect prints them, and a read through any alias sees a later
write. scriptc kept each member in a namespace-mangled module global, so
`Object.keys(fmt)` answered `[]` and the properties were invisible.

Every exported member of a merged block is now an own-property table entry of
the function object (callable-properties.ts, the same table expando writes and
`Object.assign(fn, ...)` use), written at the member's source position so the
own-key order follows the block, and read through the same keyed operations
the `any` view of the function emits. Bare reads inside the block read the
table; bare writes have no storage to name and are refused with the qualified
spelling.

Namespaces that do not merge with a function keep their flattened module
globals byte for byte. Members the table cannot represent (a class, an enum, a
nested namespace, an import= alias, a destructuring pattern, a binding without
an initializer, all of which Node's transform either skips or stores
differently) are refused by name at collection.

`in` on a typed callable receiver asks the table (`dynHasKey`), matching the
`any` view, so `"digits" in fmt` answers as Node does.

Corpus 9227 pins the merge both ways; two diagnostics fixtures pin the bare
write and the unsupported member forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node:test's `Mock<F>` is `F & { mock: MockFunctionContext<F> }`: the value IS
the function. scriptc mapped the intersection to a separate hybrid record
`{ %call: F, mock }`, so `typeof mockFn` answered "object", the value was not
the function, and every consumer needed a `%call` unwrap.

`Mock<F>` now maps through the ordinary callable-intersection path to F's func
type, and the mock lowering returns the wrapper closure itself, defining the
context as a non-enumerable own property of its table (`dyn.defineProperty`,
so `Object.keys(mockFn)` stays empty and `mockFn.mock` reads the live log).
The mock-specific type-mapper machinery (`mapHybridCallableRecord`,
`isMockHybridIntersection`) and `mock-shapes.ts`'s `mockType` are deleted.

`hybridCallUnwrap` and the `%call` arms of `coerceToExpected` now handle only
callable CLASS instances (the chalk-shim builder), the one remaining `%call`
producer.

`mock.method` keeps two wrappers: the object's field records the object as
`this`, while the returned mock records `this` as undefined, as Node's bare
call of the returned function does (corpus 5810).

The proxy-vs-own-property reflection difference is documented on the
limitations page; corpus 9228 pins `typeof`, identity, aliasing and the call
log both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two refusal fixtures record their rendered diagnostics, and the four new
fixtures record their TypeScript preflight/order baselines. Every existing
baseline is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node serves MockFunctionContext through a proxy: reads of `mockFn.mock`
answer, but the proxy has no own property, so `"mock" in mockFn` is false
and `Object.getOwnPropertyNames` omits it. scriptc stores a non-enumerable
own property instead, which is what makes `Object.keys` empty like Node;
the comment claimed the proxy made `in` true, which the differential program
had already shown is not the case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A unit's record cannot carry the merged-namespace member registry or the
per-file function-member store list: both are filled by splitFiles before any
unit lowers, and a unit that grew either would no longer replay. Register them
with checkedTable beside nsBlocks, the same collection shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The function member column listed its scr_json.c builds by hand: one alone and one beside the Node island build. #3040 adds a column to the same unit the same way, for WeakRef, and neither list names the other's feature. With both merged, a program that writes a function member and constructs a WeakRef had no build carrying both defines and linked one without SCR_FN_MEMBER_PROPS or without SCR_WEAK_REF.

withBuildColumn adds a column as a twin of every build the unit has so far, so the unit has a build for every combination whichever order the columns land in, and whether or not another change adds or removes one. The builds are the same as before; the one beside the island build is now named fn-member-props-node-island.

A test checks every unit: each combination of the features its builds name has exactly one build, carrying each feature's defines.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A function inherits `call`, `apply`, `bind` and `toString` from
Function.prototype, so reading one as a value answers a function: with no
own property of that name, and again after an own property that shadowed
it is deleted. The checked-dynamic read (scr_dyn_fn_get) answered own
properties, `name`, `length`, `constructor`, `apply` and builtin statics,
and nothing else, so `typeof fn.call` was "undefined" where Node answers
"function", and `delete fn.call` left nothing behind to read. Member calls
were never affected: they dispatch through the native call/bind lowering.

The SCR_FN_MEMBER_PROPS build of scr_json.c now answers the inherited
member when the function has no own property of that name: one builtin
value per name, shared by every function, with Node's name and length,
that runs the builtin over its `this` (`fn.call.call(fn, thisArg, ...)`,
`fn.bind.call(fn, thisArg)`). Function.prototype.toString has no native
source text, so its value keeps the refusal the member call
`fn.toString()` gives, and a string conversion does not mistake the
inherited builtin for a formatter the program installed. Every added line
is inside the #ifdef, so the default object is unchanged.

The fnMemberProps rule selected that build for a program that writes,
defines or deletes one of the protocol names. It now also selects it for a
program that spells `call` or `bind` as a dynamic read key, so a plain
`typeof fn.call` is right without any write, and for one that deletes a
`toString`, which reveals the inherited member. A spelled read of
`toString` cannot select the build: `String(value)` on a dynamic value
lowers to that very read in programs that never name the member. A read
through a key computed at run time proves nothing either; both keep the
build the rest of the program selects.

Corpus 8887 pins the inherited values (typeof, hasOwnProperty, `in`,
identity across functions, name and length), held values called on a
function and on a non-callable, shadow then delete, deletes of members
that were never own, re-adding after a delete, an own toString, and the
same reads through computed keys; 8888 is its JavaScript twin.
runtime-features.test.ts pins the rule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two findings from the review of #3157.

A literal bracket read (`fn["call"]`) evaluates its key into the dynamic
tier, so the read carries a boxed string literal, which the rule did not
count as a spelled key: in a program with no other trigger the read still
linked the default build and answered undefined. The rule now unwraps the
box.

The inherited `call` value refused every receiver that is not a function
value, so `fn.call.call(proxy, thisArg, ...)` threw a TypeError for a
callable Proxy, which Node invokes. It now runs any callable. `bind` and
`toString` build on the function's own closure and text; on a callable
that is not a function value they refuse by name (SC2020) instead of
claiming the receiver is not callable.

8888 pins the Proxy receiver and the bracket reads;
runtime-features.test.ts pins the boxed key.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Four cases the review bot found in the function+namespace merge:

- `namespace f.inner { … }` merged with `function f` stored the inner block's exports on `f` itself, so `Object.keys(f)` answered the inner names where Node answers `inner`. The dotted declaration is refused by name, like the `export namespace inner` spelling.
- A bare reference to an exported function read the property the block had not stored yet and failed its checked conversion. Node's transform keeps the declaration as the block's own hoisted binding and does not qualify the reference, so the bare name is the declared function, before the store and after the property is replaced.
- A qualified call named the declaration, so after `Object.assign(fmt, { helper })` it still ran the old function. The call now reads the property, as the qualified read already did.
- An exported interface, type alias or type-only namespace beside a runtime member was refused with SC1090. They are no properties of the function object and are skipped, as in a namespace that merges with no function.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The base branch rewrote the own-member scan around ownMemberOp (a keyed
library call or a call into the lifted define helper) and
mayHoldCallable; this branch added the delete of a `toString` to the
same statements. The scan keeps the base's structure and tests the
revealed-by-delete names on the operation it found.

Co-Authored-By: Claude Code <noreply@anthropic.com>
main merged #3097 (delete an undeclared record key from the record's kept
source), which adds the library call record.sourceDelete beside the two
this branch adds, record.sourceGet and record.sourceHasKey. The four
conflicts are those entries meeting at the same place, in the symbol
table, the signatures, the may-throw list and the LLVM dispatch; each
keeps both sides' entries.

Co-Authored-By: Claude Code <noreply@anthropic.com>
scr_record_source_get.c was listed as a base runtime source, so every
executable linked its object. Nothing of it is reachable in a program
that never projects a record, but the object still leaves its FILE symbol
in the symbol table: a fully typed benchmark built from this branch
carries `scr_record_source_get.c` there, which no executable built from
main has, so every program's executable differed from main's.

The unit is now optional, selected by its two library calls
(record.sourceGet, record.sourceHasKey) through the runtime feature
recordSourceGet, the way main gates the kept-source delete
(recordSourceDelete): the pack matrix lists it under
OPTIONAL_RUNTIME_SOURCES, the from-source lane's RUNTIME_UNITS, the
library archive and the library flavors gate on the same fact, and the
executable and library cache identities carry it.

This is the change the branch's author made locally on 2026-11-05 and
never pushed (the commit was unsigned); it is applied here on top of the
review fixes and main, with the presence test added to the rule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflicts. main's #3245 lists the required link feature keys once;
recordSourceGet is an optional key (absent when off), which that list
does not name.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The pack test that asks every unit for one build per combination of its features enumerated all of them. A change that declares an implication between two features (RUNTIME_FEATURE_IMPLIES, as the FormData work does: `formData` implies `webBodies`) adds no build for the combination no program can have, so whichever of the two changes merged second would fail this test on `scr_fetch.c with formData`.

The test now reads the table when the matrix exports one: a combination that names a feature without what it implies must have no build, and a feature's own defines are those of the smallest build a program with it can link. Without the table every combination is possible, as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Store function+namespace merges and mock functions in the callable table
`import x = fmt.member` on a function+namespace merge failed to compile
when the member was a `let` or a `var` (review): the alias statement
looked for the member's module global, and a merge member has none since
its storage became the function object's own property. The alias of a
`const` or a function member compiled and read the member live, or the
declaration, where Node's `const x = fmt.member` keeps the property it
copied: after `Object.assign(fmt, { digits: 4 })` the alias answered 4
where Node answers the old value, and an alias of a function whose
property had been replaced before the alias ran the declaration.

The alias of every merge member now owns storage, and the alias
statement fills it from the function object's table. Aliases of members
of a namespace merged with a class or an enum were right and are pinned
too.

Two neighbours of the same kind:

- A generic or an overloaded function exported from a merged namespace
  crashed the compiler (an uncaught PoisonError where its property is
  stored). It has no single value to store, so it is refused by name.
- A reference to an alias from a function written above the alias
  statement read the alias's storage: with storage for every member's
  alias that is a call through an unset function, and for a string
  alias it was a segmentation fault on main. Node's transform leaves
  such a reference unbound and throws ReferenceError whenever the
  function runs, so the reference is refused by name, as the same
  reference at module level already was.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
…ntity without one

A slot declared as an array of generators became a checked-dynamic value
(the canonical mapping now applies the array storage contract). Two things
followed that this settles once every body is lowered (generator-arrays.ts).

In a build that runs a package or a module in a Node island, a generator
that crosses the boundary is not the same value on the other side yet (the
island value matrix pins that for parameters and fields). The array form
added silent cases of it: eleven observations in four matrix cells
(generator.p, generator-lazy.p, generator-infinite.p, iter-array.p) went
from a build-time refusal to a wrong answer. Such a build now keeps the
array type these slots had before, which has no storage, so each use that
was refused by name is refused by the same rule (SC1101 for the conversion
to unknown[], SC8010 for the element handed to the island), and a program
that reaches the end of lowering with such a slot, where the backend stopped
with an internal error, is refused by name (SC8010). The same refusal covers
a program that loads a module into an island itself (requireModule). The
three matrix entries pinned as wrong are refusals again: the table is the
one on main for these cells.

Without an island the array compiles as before, and a generator read back
out of it is the generator that went in. The conversion of a checked-dynamic
value into a generator-typed slot is the iterator adapter, a new generator
that steps its source: `const list = [g]; const h = list[0]!; h === g` was
false. In a program that has such an array every adapter call first asks
whether the value is the native generator of exactly the slot's type and
then takes it through the checked cast. Programs without such an array keep
their adapter calls, so no program that compiled before changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The second lang train lands first; this branch builds on it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict. Against main the branch is its eleven files; the island value
matrix table is main's with the three generator-lazy.p entries back to
refusals.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict. Main now holds the island value matrix re-pin (#3367), which
pinned the generator cells as main produced them. The table is regenerated
on this merge in the next commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…assed through

A function declared with an `any` parameter, stored in a slot whose
function type names a record with required members and called back
without that type, threw before it ran: the caller's argument was
converted into the slot's parameter type first, a checked conversion from
an untyped value ("expected string at $.diff, got undefined"). Node runs
the function. Neither end of that call names the slot's type.

  function show(payload: any) { … }
  const def: { show: (payload: { diff: string }) => string } = { show };
  const loose: (payload: any) => string = def.show;
  loose({});            // Node: show runs. Before: TypeError
  def.show({} as any);  // the program's claim: still throws

The rule sits beside the one for argument counts (function-views.ts): a
consumer that hands a view more than its type admits starts from the
function the view forwards to.

- A conversion of the slot's function type to one whose parameter is
  wider (untyped where the slot names a type, a union with more arms, a
  record that requires less, a base class) tests for the views that
  convert that parameter and returns the function itself, or the function
  converted to the wide type when its own type is another.
- The same adapter asks for the checked-dynamic boundary's view too (a
  callable that crossed `any` before it was stored in the slot) and checks
  the callable itself into the wide type.
- A box sees through a view in the backend's box builder for the view's
  signature, the one place every box of that signature is made: in a
  program that boxes a function of that type, the view's body carries a
  mark (IrFunction.boxesSource). This replaces the
  `%fn.box` helper, which only saw unnamed `dynFrom` expressions, so a
  function boxed by name or inside a record, array, Map, instance or call
  result is reached as well, for the argument-count views too.
- A call the compiler typed by itself (an untyped package function
  specialized for the callback it is handed, a local declared `any` that
  holds a function) asks whether the callee is a view and then checks the
  untyped argument against the function's own parameter
  (callValue.untyped; JavaScript source is known by its file).
- A function literal whose parameter annotation spells `any` keeps its own
  signature in a function-typed destination instead of adopting the
  slot's, so it is a function behind a view like any other.

What still checks: a call through the slot's own type, the parameter type
the function itself declares, and a function whose parameter type is the
slot's (contextually typed, or a generic function instantiated there).

No run-time representation is added: a view already holds the function in
its first capture. A typed call through the slot is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 10 commits November 7, 2026 16:16
No conflict. Main now holds train islands-1 (#3377), which regenerated the
island value matrix; the table is regenerated on this merge in the next
commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
- record.sourceHasKey (#2999) says whether it can throw: it cannot. It asks
  the record's kept view for an own key; no accessor runs
  (builtin-attributes.test.ts, main's #3227).
- The run-time refusal of Function.prototype.toString on a dynamic value
  (#3053's build of scr_json.c) names its diagnostic code, SC2020, as its
  neighbour does (scripts/runtime-refusals.mjs --strict, main's #3137). The
  refusal baseline is regenerated in the next commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…nvoke.c; regenerate the refusal baseline

Main's islands-1 gives scr_dyn_invoke.c a Node island build. #3053's
fn-member-props column was added in front of it, so a program with both
features had no build that carries both defines (the combination test of
runtime-pack-matrix.test.ts names it). The column now follows the island
build and doubles it.

packages/runtime/refusal-sites.json is regenerated with
scripts/runtime-refusals.mjs --write: #3053's three refusal sites in
scr_json.c join the list (393 sites, each with a code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…/lang-3

No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main now holds trains cli-5, startup-1 and cli-4.

Conflict, frontend/canon-map.ts, the array case: main added the rule that
a type that is itself an array of checked-dynamic elements is the
checked-dynamic value, at the place where this branch notes an array of
generators. Both rules stay, each for its element kind.

The island value matrix and conformance list are regenerated on this
merge in the next commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Conflicts:
- frontend/canon-map.ts, the array case: #3366's note for an array of
  generators and main's rule for an array of checked-dynamic elements;
  both stay (the resolution #3366's own branch has).
- frontend/lowering/lower-modules.ts: #3053's store of a hoisted function
  member and main's load of a closed island's generated module were added
  in front of the same statement; both stay.
- ir/builtin-never-throw.ts: main's dyn.classValueHash and #2999's
  record.sourceHasKey at the end of the list; main's first.
- ir/codec.generated.ts: regenerated on the merged sources
  (pnpm generate:ir-codec).

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict: its merge of main is already here.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main now holds the landing rollup (#3395: train lang-2, startup-2,
islands-3), the load-cost train and perf-4.

Conflicts:
- executable-features.ts, native-link-info-core.ts, native/contracts.ts,
  executable-cache.ts, index.ts: main's moduleCompileCache and #3053's
  fnMemberProps were added at the same places; both rows stay, main's
  first.
- ir/builtin-never-throw.ts: main's two module.compileCache rows, then
  #2999's record.sourceHasKey.
- frontend/lowering/lower-modules.ts: #3053's store of a hoisted function
  member, then the rollup's closed-island block with its continue; both
  stay.
- ir/codec.generated.ts: regenerated on the merged sources
  (pnpm generate:ir-codec, layout 772ae1c67381f949).

Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 8, 2026 9:08am UTC

Comment thread packages/runtime/src/scr_dyn_invoke.c Resolved
@cramforce

Copy link
Copy Markdown
Contributor Author

Zero-cost gate on the pushed head aa4b28b88c against main cfe2e1b6ba, --gate-corpus=all (gate-only round, Vercel Sandboxes, 35.0 minutes): PASS. 5,528 identical, 315 fail to build on both sides (the island programs, built without a libnode pack on both sides), 81 allowed (the rows of the description, by member), 2 of the train's new programs that main cannot build, nothing that differs outside the allow list, no runtime-only row. Compile time, all within tolerance: benchmarks -0.01% [95% CI -0.63%, +0.74%], self-compile -0.69% [-0.82%, +0.36%], fixtures +0.56% [-0.13%, +1.24%]. The round's type check (the candidate's pnpm -r build on a fresh tree) passes.

@cramforce

Copy link
Copy Markdown
Contributor Author

HOLD: do not merge yet.

The review bot left two findings on this train that are not reproduced or refuted yet: scr_dyn_invoke.c:1336 (an own call/apply/bind member whose arguments replace that member invokes the replacement) and scr_record_source_get.c:21 (a retained getter read during record widening gets the placeholder-only source view as this). Today most such findings turned out to be real and silent, so each is checked against Node, main and this head first. The hold is lifted in a comment here once both are fixed forward or answered with evidence.

🤖 Generated with Claude Code

A call through an own call, apply or bind property of an untyped function read the property inside the dispatch, after the arguments had run. An argument that replaced or deleted the property changed the function the call ran, and an argument that stored one where there was none was called in place of the builtin. JavaScript reads the callee first.

The prepare step that reads the callee of every other receiver before the arguments now reads the own property of a function too, and a prepared call that found none runs the builtin. A caller without a prepare step reads the property when it calls, as before. Only the builds of scr_dyn_invoke.c with function member properties change.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

Hold lifted: both review-bot threads are answered and resolved.

  1. scr_dyn_invoke.c (an own call/apply/bind replaced by the call's arguments): real and new with this train; fixed in 42882dc, with the cases added to corpus 9225. Only the function-member-property builds of scr_dyn_invoke.o change (32 of 1,385 runtime objects); gate against main cfe2e1b6ba, whole corpus: 5,528 identical, 315 fail to build on both sides, nothing differs outside the allow list.
  2. scr_record_source_get.c (a kept getter that reads declared fields through this): wrong on main too (undefined; Node g/G; this PR undefined/undefined), no answer that is right on main changes; queued as its own fix. Details on the thread.

The lanes and bootstraps on 42882dc are CI's run on this push; the full sandbox round (both lanes, both native bootstraps) ran on the tree before the merge of main and the fix, as in the description.

@cramforce
cramforce merged commit 5119aff into main Nov 8, 2026
120 of 137 checks passed
@cramforce
cramforce deleted the train/lang-3 branch November 8, 2026 11:56
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
…lang-5 is cut on top of lang-3

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
Main gained train node-1 (vercel-labs#3353) and train cli-18. No conflict with lang-3 (vercel-labs#3405) or lang-4 (vercel-labs#3410), which this branch is cut on.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 42882dc3 Deployed Nov 8, 2026 by vercel[bot]
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.

2 participants