#3092

Compile classes extending host globals and derive builtin function values

Opencramforcewants to merge intofrom

Implements wave-2 decisions 3a = A and 5 = A from the pi decision package.

3a — class extends a Node global native code lacks

class CodexSocket extends WebSocket { ... } now compiles as an ordinary class value. The heritage read answers the constructor value host-global-values.ts already provides, and the subclass adopts it as its callableBase, so super(url) lowers to a call of that value and throws its SC2020 host-global fence ('WebSocket' is a Node global that native code does not provide). No instance can therefore exist:

  • a statically visible new Sock(...) (including a new of any further subclass, which inherits the fence through the base chain) reports the same SC2020 at the construction site, with the host-global Node island hint;
  • a construction through an opaque class value (a parameter, a class expression binding, an untyped slot) builds and throws the fence at run time.

The class value itself is observable exactly as Node's: typeof, name, length, identity through an alias, the prototype.constructor pair, statics, and subclassing. That is what lets a branch only Bun takes (process.versions.bun is absent natively) compile.

A static the class inherits from the global (Sock.OPEN, Sock.probe(…)) is the global's own surface, which native code does not provide. Reading or calling it through the subclass, a further subclass or an alias reports the same SC2020 the read off the global reports. A static the program declares in a form with no lowering there (an accessor, a field without an initializer) names the member. Neither answers undefined.

Target: pi openai-codex-responses.ts:1017 clears.

5 — library functions as values, derived from their direct calls

derivedBuiltinValueFn gives a table row a value shape from the row's own direct-call ABI instead of a per-member contract: the value is a closure whose body is exactly the libCall a direct call with arguments of those types compiles to, interned once per member (%builtin.value.<fn>:<typeKey>:<adapterShape>), so stat === fsp.stat holds across import spellings and Node's name is kept. Derivation is passive — direct-call lowering is untouched, so typed code is byte-identical (zero-cost gate below).

The derived closure takes the row's parameters and nothing else, while Node's function may read arguments past them (mkdtemp's options, realpath's encoding), and a slot converts a function with fewer parameters by dropping the extra arguments. So the value exists only where the read's slot bounds what a call passes (derivedValueSiteUse):

  • a typed slot whose every call signature passes at most the row's parameters, or a read that never calls the value (===, typeof, .name), takes the value;
  • a typed slot that passes more is the direct call with those arguments, which the row does not lower: it refuses by name with both counts;
  • an any/unknown or inferred slot, a JavaScript source, and the dynamic module object keep the refusal they have on main.

Rows without a value keep the by-name refusal where a source-level function type cannot spell the shape or the value would bypass call-site validation:

  • CALL_SHAPE_MEMBERS — members whose call lowering reads the call node (the readFileSync utf8 fence, statSync's Dirent option, setPriority's argument completion, …);
  • rest packs (path.join), omitted-argument defaults, checked-dynamic forms, fs callback dispatchers (fs.read), and any row whose parameter or result is dynamic;
  • modules whose call lowering lives in their own dispatcher (crypto, zlib, http, stream, …);
  • rows with an explicit valueParams contract are left exactly as they were — one representation decides each adapter.

Target: pi ls.ts:45 (stat: fsStat in the tool's record literal) now type-checks and takes its value from the row; the record it is written into still needs the class-to-interface view tracked as decision 2/3b (see below).

Evidence

  • tests/corpus/host-global-extends-class.ts — differential: class value shape, identity, statics, subclass; the unconstructible constructors stay in a branch native code never takes.
  • tests/corpus/builtin-derived-function-values.ts — differential: identity (stat === fsp.stat, ops.stat === stat), calls through the value (sync and awaited async), passing the value as a callback argument, Node's name.
  • tests/diagnostics/host-global-extends-construction.ts + snapshot — both new sites report SC2020.
  • tests/diagnostics/host-global-extends-inherited-static.ts + snapshot — inherited statics through the subclass, a further subclass, an alias and a call report SC2020; an accessor static names the member; the program's own statics compile.
  • tests/diagnostics/builtin-derived-value-slot-arity.ts + snapshot — a derived value refuses in a parameter slot and a field slot that pass a second argument, and in an unknown slot.
  • tests/harness/host-global-dynamic.test.ts — the runtime fence for a dynamic construction (and a subclass of the subclass), plus the instanceof Function answer below.
  • packages/compiler/src/frontend/lowering/builtins/module-call-values.test.ts — the derivation's decision table: every shape-validated module derives, every exclusion names a real member, representative rows derive their ABI and refuse the shapes a source-level type cannot spell.

Gates

All on head 0dc6891a9 unless a line says otherwise.

  • pnpm lint, pnpm format:check: clean.
  • pnpm test:ts7 --baselines-only: "Every TypeScript preflight/order fixture has a recorded baseline." The five new fixtures' baselines are in this branch (one module in order, no preflight diagnostics each), and the order-parity batches over tests/diagnostics/ pass (15/15).
  • focused vitest: unit test 8/8, host-global harness 12/12 (plain and SCRIPTC_SAN=1), the diagnostics corpus 299/299, the two corpus programs plus the neighbouring function-value and class-extends programs (34 plain), both corpus programs sanitized.
  • zero-cost gate (bin/validate --skip=lanes,bench,t262,san-bootstrap --gate-corpus=all, base d44d6b7a3): PASS. 5175 identical, 95 both-fail, no differing program and no runtime-only row. Bench geomean +0.08% [95% CI -0.53%, +1.37%], fixtures -0.76% [-1.73%, +0.20%], both within tolerance; self +0.45% [-0.93%, +2.31%], which the tool reports as inconclusive at this run count. The candidate compiler source builds with the candidate (689.6 s).
  • native bootstraps (tests/harness/self-hosting-native-driver.test.ts, 32 vCPU / 64 GB Sandbox): both pass on 0dc6891a9. Plain, all contracts: 2,312 s (seed 890 s, command probes 368 s, self-rebuild 700 s, Node/native IR and LLVM comparison 62 s, rebuilt compiler emits itself 718 s). SCRIPTC_SAN=1, all contracts: 5,331 s (seed 1,362 s, command probes 860 s, sanitized frontend lowers the compiler 3,711 s, sanitized emitter rebuilds it 3,962 s, IR and LLVM comparisons 77 s and 7 s).
  • pi (scriptc coverage through cov.sh, 32 vCPU, main at the merge base against this branch): below.

pi delta

scriptc coverage over pi's native entry, rpc entry, and codemode, base (main) vs this branch:

reportstatements analyzedcompiled staticallydiagnostics
entry, before5528855117 (99%)SC1090 67, SC2020 29, others unchanged
entry, after5529655127 (99%)SC1090 65, SC2020 30, others unchanged
rpc, before5527155100 (99%)same as entry
rpc, after5527955110 (99%)same as entry
codemode17511747 (99%)unchanged

Both target sites clear:

  • packages/ai/src/api/openai-codex-responses.ts:1017:44 — SC1090 extending classes not declared in the program ('WebSocket') is gone; the class extends WebSocket value now compiles.
  • packages/coding-agent/src/core/tools/ls.ts:45:8 — SC1090 library functions as values (call 'fsStat' directly) is gone; stat: fsStat in the record literal now takes the derived value.

The numbers are the same on 0ded4f73b and on 0dc6891a9; the review fixes change no pi site.

Each of those statements then reports its next fence, and the stacked PR clears both:

  • ls.ts:46:11: library functions as values (call 'fsReaddir' directly). fs/promises.readdir is listed as a call-shape row here.
  • openai-codex-responses.ts:1027:6: SC2020 'string | URL.toString' … has no scriptc lowering yet, inside the now-compiled class's constructor.

ls.ts:45 compiles but is not usable yet: the slot's return type is the structural { isDirectory: () => boolean }, Stats has no view as that record, and the call through the slot throws a TypeError that names both types. That needs the class-to-structural view (decision 2/3b family), not this PR.

Net: two fewer uncompiled statements in the entry report (171 to 169), ten more compiled statically, and the only site-level change in either direction is those three lines.

Known limitations (deliberate, not regressions)

  • A derived value stored in a slot with the row's parameters and then assigned from that slot to a wider one loses the extra argument: const ops: { make: (prefix: string) => Promise<string> } = { make: mkdtemp } followed by const wide: (prefix: string, options: { encoding: "hex" }) => Promise<string> = ops.make. The second conversion wraps an opaque function, and main drops the argument the same way for a program function with an optional parameter. It needs the general fix and is recorded as a follow-up; the one-step forms refuse (above).

  • Sock instanceof Function answers false natively where Node (whose class chain reaches Function.prototype) answers true. The stacked PR folds the answer for these classes. instanceof uses the .prototype walk, and a class-built value does not carry that property toward Function.prototype. Fixing it needs either a static fold (which would break byte-identical IR for typed programs) or a runtime [[Prototype]] change; the harness test pins today's answer so the divergence stays visible. instanceof WebSocket answers false, as it does for every value native code produces.

  • A derived value's length is its required-parameter count, so a row that omits Node's trailing options (fs/promises.realpath, fs/promises.mkdtemp) reports a smaller length than Node. Typed code cannot read .length of a function value today (SC2020), so this is unobservable in type-checked source.

  • docs/content/limitations/builtin-function-values.mdx lists a hand-picked subset of filesystem function values; it is intentionally left narrower than what the derivation now accepts. Refreshing it belongs with the compatibility-ledger update that names the new evidence.

🤖 Generated with Claude Code