From de782764c2039b5652dda4626383047316c1024a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 14 Oct 2026 16:55:54 -0700 Subject: [PATCH 01/85] Version typed-array loops on one bounds guard and tag typed-array memory for alias analysis Typedarray element stores could alias every header and module-global load, so each access reloaded the length, data pointer and global receiver slot and kept its own bounds branch. Element accesses now share one TBAA category (views of any element type may share a buffer), header loads another, and whole-slot loads of bytes/number globals their own, so LICM keeps them in registers. The data pointer loads beside the length, ahead of the branch. ScrBytes lengths and data pointers are fixed at construction, so innermost synchronous for loops whose accesses use a statically ranged index or an affine counter index (k * counter + invariant + c) are versioned: one test before the loop proves every access in bounds (bounding the counter so every affine operand stays an exact safe integer), and the copy behind it runs without per-element checks. The checked loop keeps exact out-of-bounds, NaN, fractional and DataView-throw behavior. --- .../compiler/src/backend/llvm/byte-numbers.ts | 7 +- .../compiler/src/backend/llvm/bytes-alias.ts | 23 + .../backend/llvm/bytes-loop-versions.test.ts | 210 +++++++ .../src/backend/llvm/bytes-loop-versions.ts | 561 ++++++++++++++++++ .../src/backend/llvm/counted-loops.ts | 51 ++ packages/compiler/src/backend/llvm/emitter.ts | 122 ++-- .../compiler/src/backend/llvm/expr-bytes.ts | 64 +- .../src/backend/llvm/expr-containers.ts | 2 +- .../src/backend/llvm/expr-primitives.ts | 2 +- .../5467-typed-array-loop-versions.ts.json | 6 + .../5468-typed-array-loop-aliasing.ts.json | 6 + .../corpus/5467-typed-array-loop-versions.ts | 238 ++++++++ .../corpus/5468-typed-array-loop-aliasing.ts | 87 +++ 13 files changed, 1303 insertions(+), 76 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/bytes-alias.ts create mode 100644 packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts create mode 100644 packages/compiler/src/backend/llvm/bytes-loop-versions.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5467-typed-array-loop-versions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5468-typed-array-loop-aliasing.ts.json create mode 100644 tests/corpus/5467-typed-array-loop-versions.ts create mode 100644 tests/corpus/5468-typed-array-loop-aliasing.ts diff --git a/packages/compiler/src/backend/llvm/byte-numbers.ts b/packages/compiler/src/backend/llvm/byte-numbers.ts index c51d03744..9001e015d 100644 --- a/packages/compiler/src/backend/llvm/byte-numbers.ts +++ b/packages/compiler/src/backend/llvm/byte-numbers.ts @@ -4,6 +4,7 @@ import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; import { emitTruncF64 } from "./trunc.js"; +import { bytesElementAlias, bytesHeaderAlias } from "./bytes-alias.js"; /** Supported LLVM targets are little-endian. All memory accesses use align * 1: Buffer fields and DataView windows can start at arbitrary byte offsets. */ @@ -79,7 +80,7 @@ export function emitByteNumber( capacity = B.tmp(); room = B.tmp(); B.line(`${lengthPtr} = getelementptr inbounds %ScrBytes, ptr ${receiver.name}, i64 0, i32 1`); - B.line(`${length} = load ${size}, ptr ${lengthPtr}`); + B.line(`${length} = load ${size}, ptr ${lengthPtr}${bytesHeaderAlias(host)}`); B.line(`${room} = icmp uge ${size} ${length}, ${spec.width}`); B.line(`${capacity} = sub ${size} ${length}, ${spec.width}`); if (integer) { @@ -221,14 +222,14 @@ export function emitByteNumber( } } const stored = endianBits(host, raw, spec.width, little); - B.line(`store i${bits} ${stored}, ptr ${pointer}, align 1`); + B.line(`store i${bits} ${stored}, ptr ${pointer}, align 1${bytesElementAlias(host)}`); if (!spec.dataView) { fastResult = B.tmp(); B.line(`${fastResult} = fadd double ${offset.name}, ${f64Lit(spec.width)}`); } } else { const loaded = B.tmp(); - B.line(`${loaded} = load i${bits}, ptr ${pointer}, align 1`); + B.line(`${loaded} = load i${bits}, ptr ${pointer}, align 1${bytesElementAlias(host)}`); const raw = endianBits(host, loaded, spec.width, little); fastResult = B.tmp(); if (spec.floating) { diff --git a/packages/compiler/src/backend/llvm/bytes-alias.ts b/packages/compiler/src/backend/llvm/bytes-alias.ts new file mode 100644 index 000000000..9aaeaa6c7 --- /dev/null +++ b/packages/compiler/src/backend/llvm/bytes-alias.ts @@ -0,0 +1,23 @@ +import type { LlvmEmitterContext } from "./expr-context.js"; + +/** Alias categories of typed-array storage. A ScrBytes header (length, + * data pointer, shared backing) is fixed at construction and only ever + * written by runtime code, never by emitted code. Element storage is one + * category for every element type: views of different types may share a + * buffer. Neither overlaps the other, a native field, array storage or a + * module global's slot, so element stores leave header loads and global + * receiver slots loop-invariant. Untagged accesses and calls still alias + * every category. */ +export function bytesHeaderAlias(host: LlvmEmitterContext): string { + return host.memoryAliasAttachment("bytes:header"); +} + +export function bytesElementAlias(host: LlvmEmitterContext): string { + return host.memoryAliasAttachment("bytes:elements"); +} + +/** A module global's slot is its own category for whole-slot loads of a + * scalar or typed-array binding. Stores stay untagged (and so alias it). */ +export function globalSlotAlias(host: LlvmEmitterContext, id: string, type: string): string { + return type === "bytes" || type === "f64" ? host.memoryAliasAttachment(`global:${id}`) : ""; +} diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts new file mode 100644 index 000000000..d8ec036ff --- /dev/null +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -0,0 +1,210 @@ +import { expect, test } from "vitest"; +import { + BOOL, + F64, + VOID, + type IrExpr, + type IrFunction, + type IrLocal, + type IrModule, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; +import { analyzeIntegerRanges } from "../../ir/integer-ranges.js"; +import { matchIntegerCountedForLoop } from "../../ir/integer-loops.js"; +import { findInitializedByteLoopBindings } from "./byte-windows.js"; +import { matchBytesLoopVersion, type BytesLoopVersion } from "./bytes-loop-versions.js"; +import { emitLlvmModule } from "./emitter.js"; + +const loc = { file: "versions.ts", start: 0, end: 0 }; +const f64s: IrType = { kind: "bytes", elem: "f64" }; +const i32s: IrType = { kind: "bytes", elem: "i32" }; +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const ref = (localId: string, type: IrType = F64): IrExpr => ({ + kind: "varRef", + localId, + type, + loc, +}); +const bin = (op: string, left: IrExpr, right: IrExpr): IrExpr => + ({ kind: "bin", op, left, right, type: F64, loc }) as IrExpr; +const get = (receiver: IrExpr, index: IrExpr): IrExpr => ({ + kind: "bytesIntrinsic", + method: "get", + receiver, + args: [index], + invalidNaN: true, + type: F64, + loc, +}); +const set = (arr: IrExpr, index: IrExpr, value: IrExpr): IrStmt => ({ + kind: "bytesSet", + arr, + index, + value, + loc, +}); +const counted = ( + body: IrStmt[], + limit: IrExpr = ref("n"), + counter = "i", +): IrStmt & { kind: "for" } => ({ + kind: "for", + init: { kind: "varDecl", localId: counter, init: num(0), loc }, + cond: { kind: "bin", op: "<", left: ref(counter), right: limit, type: BOOL, loc }, + update: { kind: "assign", localId: counter, value: bin("+", ref(counter), num(1)), loc }, + body, + loc, +}); +const local = ( + id: string, + type: IrType, + mutable = false, + extra: Partial = {}, +): IrLocal => ({ + id, + name: id, + type, + mutable, + ...extra, +}); + +function fn(body: IrStmt[], extra: IrLocal[] = []): IrFunction { + return { + name: "kernel", + params: [ + { localId: "out", name: "out", type: f64s }, + { localId: "src", name: "src", type: f64s }, + { localId: "table", name: "table", type: i32s }, + { localId: "n", name: "n", type: F64 }, + { localId: "k", name: "k", type: F64 }, + ], + returnType: VOID, + locals: [ + local("out", f64s), + local("src", f64s), + local("table", i32s), + local("n", F64), + local("k", F64), + local("i", F64, true), + local("j", F64, true), + ...extra, + ], + body, + loc, + }; +} + +function match( + f: IrFunction, + loop: IrStmt & { kind: "for" }, + captured: string[] = [], +): BytesLoopVersion | null { + const ranges = analyzeIntegerRanges(f); + const locals = new Map(f.locals.map((l) => [l.id, l])); + return matchBytesLoopVersion(loop, matchIntegerCountedForLoop(loop, locals, ranges), { + locals, + invariantGlobals: new Set(), + captured: new Set(captured), + ranges, + initialized: findInitializedByteLoopBindings(f).get(loop) ?? new Set(), + proven: new Set(), + }); +} + +test("a counted loop's affine accesses get one guard and exact operand ranges", () => { + const shifted = bin("+", ref("i"), num(1)); + const loop = counted([set(ref("out", f64s), ref("i"), get(ref("src", f64s), shifted))]); + const version = match(fn([loop]), loop)!; + expect(version).not.toBeNull(); + expect(version.bounds.size).toBe(2); + expect(version.receivers.map((r) => r.ref.kind === "varRef" && r.ref.localId)).toEqual([ + "out", + "src", + ]); + expect(version.affine.map((c) => [c.scale, c.minOffset, c.maxEnd])).toEqual([ + [1, 0, 0], + [1, 1, 1], + ]); + // `n` is unproven: the guard bounds the counter so `i + 1` stays exact. + expect(version.counted?.limitGuarded).toBe(true); + expect(version.counterBound).toBe(Number.MAX_SAFE_INTEGER - 1); + expect(version.ranges.get(shifted)).toEqual({ min: 1, max: Number.MAX_SAFE_INTEGER }); +}); + +test("statically ranged indices compare one constant with the length", () => { + const masked = bin("&", bin("^", ref("i"), ref("k")), num(255)); + const loop = counted([set(ref("out", f64s), num(0), get(ref("table", i32s), masked))]); + const version = match(fn([loop]), loop)!; + expect(version.fixed.map((c) => [version.receivers[c.receiver]!.ref, c.end])).toEqual([ + [ref("out", f64s), 0], + [ref("table", i32s), 255], + ]); + expect(version.affine).toEqual([]); + expect(version.counted).toBeNull(); +}); + +test("scaled counters and body temps resolve to their receiver and index", () => { + const temps = [local("view", f64s), local("at", F64)]; + const at = bin("+", bin("*", ref("i"), num(4)), num(3)); + const loop = counted([ + { kind: "varDecl", localId: "view", init: ref("src", f64s), loc }, + { kind: "varDecl", localId: "at", init: at, loc }, + set(ref("view", f64s), ref("at"), num(1)), + // (The integer counted loop needs one direct counter index.) + set(ref("out", f64s), ref("i"), num(0)), + ]); + const version = match(fn([loop], temps), loop)!; + expect(version.receivers.map((r) => r.ref)).toEqual([ref("src", f64s), ref("out", f64s)]); + expect(version.affine.map((c) => [c.scale, c.minOffset, c.maxEnd])).toEqual([ + [4, 3, 3], + [1, 0, 0], + ]); + expect(version.ranges.has(at)).toBe(true); +}); + +test("negative factors, rebinding, captures and outer loops are not versioned", () => { + // `i * -1` is -0 at zero, so it is not treated as an affine operand. + const negative = counted([set(ref("out", f64s), bin("*", ref("i"), num(-1)), num(1))]); + expect(match(fn([negative]), negative)).toBeNull(); + const rebound = counted([ + set(ref("out", f64s), ref("i"), num(1)), + { kind: "assign", localId: "out", value: ref("src", f64s), loc }, + ]); + expect(match(fn([rebound]), rebound)).toBeNull(); + const plain = counted([set(ref("out", f64s), ref("i"), num(1))]); + expect(match(fn([plain]), plain, ["out"])).toBeNull(); + const inner = counted([set(ref("out", f64s), ref("j"), num(1))], ref("n"), "j"); + const outer = counted([inner]); + expect(match(fn([outer]), outer)).toBeNull(); + expect(match(fn([outer]), inner)).not.toBeNull(); +}); + +test("emitted loops version the body and tag typed-array memory", () => { + const loop = counted([ + set(ref("out", f64s), ref("i"), bin("*", get(ref("src", f64s), ref("i")), num(2))), + ]); + const kernel = fn([loop]); + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "__main", + functions: [ + { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, + kernel, + ], + }; + const llvm = emitLlvmModule(mod); + const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(llvm)![0]; + expect(body).toContain("bytes.version.fast"); + expect(body).toContain("bytes.version.slow"); + // The checked copy keeps its per-element tests; the guard reads lengths. + expect(body).toContain("bytes.index.invalid"); + expect(body).toMatch(/store double [^\n]*, align 1, !tbaa !\d+/); + expect(body).toMatch(/load double, ptr [^\n]*, align 1, !tbaa !\d+/); + expect(body).toMatch(/load i64, ptr [^\n]*, !tbaa !\d+/); + expect(body).toMatch(/load ptr, ptr [^\n]*, !tbaa !\d+/); + const elements = /store double [^\n]*!tbaa !(\d+)/.exec(body)![1]; + const header = /load ptr, ptr [^\n]*!tbaa !(\d+)/.exec(body)![1]; + expect(elements).not.toBe(header); +}); diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts new file mode 100644 index 000000000..d3a9b2042 --- /dev/null +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -0,0 +1,561 @@ +import type { IrExpr, IrLocal, IrStmt } from "../../ir/ir.js"; +import type { IntegerRange, IntegerRanges } from "../../ir/integer-ranges.js"; +import type { IntegerCountedForLoop } from "../../ir/integer-loops.js"; +import type { LlvmEmitterContext } from "./expr-context.js"; +import { byteNumberAccess } from "../../ir/byte-numbers.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { exactInteger, widenInteger } from "./integer-values.js"; +import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js"; +import { emitBytesHeaderLength } from "./expr-bytes.js"; + +type Node = IrExpr | IrStmt; +type VarRef = IrExpr & { kind: "varRef" }; + +/** One loop-invariant integer operand of an affine index. */ +interface Term { + expr: IrExpr; + sign: 1 | -1; +} + +/** Every access of `receiver` at a static index range ends below `end + 1`. */ +interface FixedCheck { + receiver: number; + end: number; +} + +/** Every access of `receiver` at `scale * counter + Σ terms + offset` has an + * offset in [minOffset, maxEnd - width + 1]; `maxEnd` includes the width. */ +interface AffineCheck { + receiver: number; + scale: number; + terms: Term[]; + minOffset: number; + maxEnd: number; +} + +export interface BytesLoopVersion { + /** Typed-array accesses that are in bounds whenever the guard holds. */ + bounds: ReadonlySet; + /** Root receiver bindings, read once before the loop. */ + receivers: { ref: VarRef; global: boolean }[]; + fixed: FixedCheck[]; + affine: AffineCheck[]; + /** The counted loop whose iteration space the affine checks cover. */ + counted: IntegerCountedForLoop | null; + /** The guard also requires every counter value within [-bound, bound]. */ + counterBound: number | null; + /** Integer ranges of affine index operands that hold in the unchecked + * loop (where the guard bounds the counter). */ + ranges: ReadonlyMap; +} + +export interface BytesLoopFacts { + /** The function's own locals (not module globals). */ + locals: ReadonlyMap; + /** Module globals whose slot no call can replace: stable constants and + * the private bindings of a guarded initializer. */ + invariantGlobals: ReadonlySet; + captured: ReadonlySet; + ranges: IntegerRanges; + /** Locals definitely initialized when the loop is entered. */ + initialized: ReadonlySet; + /** Accesses already proved in bounds without a guard. */ + proven: ReadonlySet; +} + +const MAX_COST = 400; +const MAX_CHECKS = 8; +const MAX_OFFSET = 2 ** 31; +const SAFE = Number.MAX_SAFE_INTEGER; +const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]); + +/** Loop versioning for typed-array bounds. A typed array's length and data + * pointer are fixed at construction (ScrBytes has no resize, transfer or + * detach), so an access is in bounds for the whole loop when its receiver + * binding and index operands cannot change while it runs. One test before + * the loop then covers every iteration: + * + * - a statically ranged integer index `[lo, hi]` (lo >= 0) needs + * `hi < length`, such as `table[x & 0xff]` or a constant-trip counter; + * - an index `k * counter + invariant + c` of a counted loop needs its first + * and last values in bounds, such as `out[base + i]` for `i < n` or + * `view.getUint32(i * 4)`. + * + * Only innermost synchronous loops are versioned, so the body is emitted + * twice at most. The checked loop runs whenever the guard fails, and keeps + * every out-of-bounds read, ignored write and NaN-index behavior. */ +export function matchBytesLoopVersion( + loop: IrStmt & { kind: "for" }, + counted: IntegerCountedForLoop | null, + facts: BytesLoopFacts, +): BytesLoopVersion | null { + const written = new Set(); + const temps = new Map(); + let cost = 0; + let innermost = true; + const scanExpr = (e: IrExpr): boolean => { + if (++cost > MAX_COST) return false; + if (e.kind === "assignExpr" || e.kind === "incDec") written.add(e.localId); + return everyExprChild(e, scanExpr, scanStmt); + }; + const scanStmt = (s: IrStmt): boolean => { + if (++cost > MAX_COST) return false; + switch (s.kind) { + case "for": + case "forOf": + case "while": + case "doWhile": + innermost = false; + return false; + case "varDecl": { + if (temps.has(s.localId) || written.has(s.localId)) temps.delete(s.localId); + else if (s.init) temps.set(s.localId, s.init); + written.add(s.localId); + break; + } + case "assign": + written.add(s.localId); + break; + case "tryCatch": + if (s.catchLocalId) written.add(s.catchLocalId); + break; + default: + break; + } + return everyStmtChild(s, scanExpr, scanStmt); + }; + if ( + !loop.body.every(scanStmt) || + !innermost || + (loop.cond !== null && !scanExpr(loop.cond)) || + (loop.update !== null && !scanStmt(loop.update)) + ) + return null; + // A body temp stands for its initializer only when it is a constant + // binding assigned exactly once, by that declaration. + const declared = new Map(); + const countDecls = (s: IrStmt): boolean => { + if (s.kind === "varDecl") declared.set(s.localId, (declared.get(s.localId) ?? 0) + 1); + return everyStmtChild(s, countExprDecls, countDecls); + }; + const countExprDecls = (e: IrExpr): boolean => everyExprChild(e, countExprDecls, countDecls); + loop.body.every(countDecls); + const temp = (id: string): IrExpr | null => { + const local = facts.locals.get(id); + const init = temps.get(id); + if ( + !init || + !local || + local.mutable || + local.boxed || + local.tdz || + facts.captured.has(id) || + declared.get(id) !== 1 + ) + return null; + return init; + }; + const counter = counted?.localId ?? null; + // A binding keeps its loop-entry value for the whole loop. + const invariant = (id: string): boolean => { + if (written.has(id) || id === counter) return false; + const local = facts.locals.get(id); + if (local) + return !local.boxed && !local.tdz && !facts.captured.has(id) && facts.initialized.has(id); + return facts.invariantGlobals.has(id); + }; + const resolving = new Set(); + const root = (e: IrExpr): VarRef | null => { + if (e.kind !== "varRef" || e.type.kind !== "bytes") return null; + const init = temp(e.localId); + if (init) { + if (resolving.has(e.localId)) return null; + resolving.add(e.localId); + const result = root(init); + resolving.delete(e.localId); + return result; + } + return invariant(e.localId) ? e : null; + }; + // A pure integer operand evaluated again before the loop. + const invariantKey = (e: IrExpr): string | null => { + if (!facts.ranges.get(e)) return null; + if (e.kind === "numLit") return Number.isSafeInteger(e.value) ? `n${e.value}` : null; + if (e.kind === "varRef") + return invariant(e.localId) ? `v${e.localId.length}:${e.localId}` : null; + if ( + e.kind === "bin" && + INVARIANT_OPS.has(e.op) && + e.left.type.kind === "f64" && + e.right.type.kind === "f64" + ) { + const left = invariantKey(e.left), + right = invariantKey(e.right); + return left !== null && right !== null ? `(${e.op} ${left} ${right})` : null; + } + return null; + }; + interface Affine { + /** The counter's coefficient; 0 when the value is loop-invariant. */ + counter: number; + terms: (Term & { key: string })[]; + offset: number; + } + // Every operand of an affine index is `counter * k + Σ terms + offset`. + // Invariant terms need proven ranges. Operands that involve the counter + // need none: the guard bounds the counter so that each of them is a safe + // integer, which makes the index's double arithmetic exact. + const shapes = new Map(); + const affine = (e: IrExpr): Affine | null => { + if (shapes.has(e)) return shapes.get(e)!; + const result = affineShape(e); + shapes.set(e, result); + return result; + }; + const affineShape = (e: IrExpr): Affine | null => { + if (e.kind === "numLit") + return Number.isSafeInteger(e.value) && Math.abs(e.value) <= MAX_OFFSET + ? { counter: 0, terms: [], offset: e.value } + : null; + if (e.kind === "varRef") { + if (e.localId === counter) return { counter: 1, terms: [], offset: 0 }; + const init = temp(e.localId); + if (init) { + if (resolving.has(e.localId)) return null; + resolving.add(e.localId); + const result = affine(init); + resolving.delete(e.localId); + return result; + } + } + if ( + e.kind === "bin" && + (e.op === "+" || e.op === "-") && + e.left.type.kind === "f64" && + e.right.type.kind === "f64" + ) { + const left = affine(e.left), + right = affine(e.right); + if (left && right && (left.counter === 0 || right.counter === 0)) { + const sign = e.op === "+" ? 1 : -1; + const offset = left.offset + sign * right.offset; + if (Math.abs(offset) > MAX_OFFSET) return null; + const terms = [ + ...left.terms, + ...right.terms.map((t) => ({ ...t, sign: (sign * t.sign) as 1 | -1 })), + ]; + const result = { counter: left.counter + sign * right.counter, terms, offset }; + if (result.counter !== 0 || facts.ranges.get(e)) return result; + } + } + // A positive constant multiple of the counter: `i * 4`, `4 * i`, + // `(i + 1) * 8`. (A negative factor turns a zero into -0.) + if (e.kind === "bin" && e.op === "*") { + const literal = + e.right.kind === "numLit" ? e.right : e.left.kind === "numLit" ? e.left : null; + const scaled = literal === e.right ? e.left : e.right; + const factor = literal?.value ?? 0; + if ( + literal && + scaled.type.kind === "f64" && + Number.isSafeInteger(factor) && + factor > 0 && + factor <= MAX_OFFSET + ) { + const inner = affine(scaled); + if (inner && inner.counter !== 0 && inner.terms.length === 0) { + const counterScale = inner.counter * factor, + offset = inner.offset * factor; + if (Math.abs(counterScale) <= MAX_OFFSET && Math.abs(offset) <= MAX_OFFSET) + return { counter: counterScale, terms: [], offset }; + } + } + } + const key = invariantKey(e); + return key !== null ? { counter: 0, terms: [{ expr: e, sign: 1, key }], offset: 0 } : null; + }; + // The operands of an index that involve the counter. + const chain = (e: IrExpr, out: Set): void => { + if (!shapes.get(e)?.counter) return; + out.add(e); + if (e.kind === "varRef") { + const init = temp(e.localId); + if (init) chain(init, out); + } else if (e.kind === "bin") { + chain(e.left, out); + chain(e.right, out); + } + }; + const termMagnitude = (terms: Term[]): number => + terms.reduce((sum, t) => { + const r = facts.ranges.get(t.expr)!; + return sum + Math.max(Math.abs(r.min), Math.abs(r.max)); + }, 0); + + const receivers: { ref: VarRef; global: boolean }[] = []; + const receiverIndex = new Map(); + const fixed = new Map(); + const affineChecks = new Map< + string, + { check: AffineCheck; nodes: Node[]; operands: Set } + >(); + const receiverOf = (ref: VarRef): number => { + let index = receiverIndex.get(ref.localId); + if (index === undefined) { + index = receivers.length; + receiverIndex.set(ref.localId, index); + receivers.push({ ref, global: !facts.locals.has(ref.localId) }); + } + return index; + }; + const access = (node: Node, array: IrExpr, index: IrExpr, width: number): void => { + if (facts.proven.has(node)) return; + const ref = root(array); + if (!ref) return; + const shape = counter !== null ? affine(index) : null; + if (shape && shape.counter !== 0) { + const operands = new Set(); + chain(index, operands); + const terms = [...shape.terms].sort((a, b) => + a.key < b.key ? -1 : a.key > b.key ? 1 : a.sign - b.sign, + ); + const receiver = receiverOf(ref); + const key = `${receiver}|${shape.counter}|${terms.map((t) => `${t.sign}${t.key}`).join(",")}`; + const end = shape.offset + width - 1; + const group = affineChecks.get(key); + if (group) { + group.check.minOffset = Math.min(group.check.minOffset, shape.offset); + group.check.maxEnd = Math.max(group.check.maxEnd, end); + group.nodes.push(node); + for (const operand of operands) group.operands.add(operand); + } else + affineChecks.set(key, { + check: { receiver, scale: shape.counter, terms, minOffset: shape.offset, maxEnd: end }, + nodes: [node], + operands, + }); + return; + } + const range = facts.ranges.get(index); + if (!range) return; + const end = range.max + width - 1; + if (range.min < 0 || !Number.isSafeInteger(end)) return; + const receiver = receiverOf(ref); + const group = fixed.get(receiver); + if (group) { + group.check.end = Math.max(group.check.end, end); + group.nodes.push(node); + } else fixed.set(receiver, { check: { receiver, end }, nodes: [node] }); + }; + const visitExpr = (e: IrExpr): boolean => { + if (e.kind === "bytesIntrinsic") { + const numeric = byteNumberAccess(e); + if (numeric) access(e, e.receiver, e.args[numeric.offsetArg]!, numeric.width); + else if (e.method === "get" && e.args[0]) access(e, e.receiver, e.args[0], 1); + } + return everyExprChild(e, visitExpr, visitStmt); + }; + const visitStmt = (s: IrStmt): boolean => { + if (s.kind === "bytesSet") access(s, s.arr, s.index, 1); + return everyStmtChild(s, visitExpr, visitStmt); + }; + loop.body.every(visitStmt); + // Bound the counter so every counter operand stays a safe integer. + let bound = SAFE; + const affineGroups = [...affineChecks.values()].filter((group) => { + let limit = SAFE; + for (const operand of group.operands) { + const shape = shapes.get(operand)!; + const room = SAFE - termMagnitude(shape.terms) - Math.abs(shape.offset); + limit = Math.min(limit, Math.floor(room / Math.abs(shape.counter))); + } + if (limit < 0 || !counted) return false; + bound = Math.min(bound, limit); + return true; + }); + const groups = [...fixed.values(), ...affineGroups].slice(0, MAX_CHECKS); + if (groups.length === 0) return null; + const bounds = new Set(); + const used = new Set(); + const ranges = new Map(); + const counterLow = Math.max(counted?.range.min ?? 0, -bound), + counterHigh = Math.min(counted?.range.max ?? 0, bound); + for (const group of groups) { + used.add(group.check.receiver); + for (const node of group.nodes) bounds.add(node); + if (!("operands" in group)) continue; + for (const operand of group.operands) { + if (facts.ranges.get(operand)) continue; + const shape = shapes.get(operand)!; + const a = shape.counter * counterLow, + b = shape.counter * counterHigh; + let min = Math.min(a, b) + shape.offset, + max = Math.max(a, b) + shape.offset; + for (const term of shape.terms) { + const r = facts.ranges.get(term.expr)!; + min += term.sign > 0 ? r.min : -r.max; + max += term.sign > 0 ? r.max : -r.min; + } + if (counterLow <= counterHigh) ranges.set(operand, { min, max }); + } + } + const kept = groups.map((g) => g.check); + const affineKept = kept.filter((c): c is AffineCheck => "terms" in c); + // Renumber the receivers the kept checks use. + const order = [...used].sort((a, b) => a - b); + const renumber = new Map(order.map((old, index) => [old, index])); + const needsBound = + affineKept.length > 0 && + counted !== null && + (counted.range.min < -bound || counted.range.max > bound); + return { + bounds, + receivers: order.map((index) => receivers[index]!), + fixed: kept + .filter((c): c is FixedCheck => !("terms" in c)) + .map((c) => ({ ...c, receiver: renumber.get(c.receiver)! })), + affine: affineKept.map((c) => ({ ...c, receiver: renumber.get(c.receiver)! })), + counted: affineKept.length > 0 ? counted : null, + counterBound: needsBound ? bound : null, + ranges, + }; +} + +/** Branches to `fast` when every access the version proves is in bounds for + * every iteration, and to `slow` otherwise (including a counted loop whose + * integer form is not established, or a module receiver not yet set). */ +export function emitBytesLoopVersionGuard( + host: LlvmEmitterContext, + version: BytesLoopVersion, + fast: string, + slow: string, +): void { + const B = host.B; + const next = (cond: string): void => { + if (cond === "true") return; + const ok = B.newLabel("bytes.version.check"); + B.condBr(cond, ok, slow); + B.startBlock(ok); + }; + const counted = version.counted; + if (counted?.guarded) next(emitCountedLoopSafety(host, counted)); + const receivers = version.receivers.map(({ ref, global }) => { + const value = host.emitReadReceiver(ref).name; + if (global) { + const set = B.tmp(); + B.line(`${set} = icmp ne ptr ${value}, null`); + next(set); + } + return value; + }); + const lengths = receivers.map((receiver) => { + const len = emitBytesHeaderLength(host, receiver); + if (host.sizeType === "i64") return len; + const wide = B.tmp(); + B.line(`${wide} = zext ${host.sizeType} ${len} to i64`); + return wide; + }); + const conditions: string[] = []; + for (const check of version.fixed) { + const ok = B.tmp(); + B.line(`${ok} = icmp ugt i64 ${lengths[check.receiver]}, ${check.end}`); + conditions.push(ok); + } + if (counted && version.affine.length > 0) { + // The first and last counter values, in ascending order. The last value + // of a non-unit step is exact: `start + ⌊(limit ∓ 1 - start) / step⌋ * + // step`. A loop that runs no iteration may compute anything here. + const startValue = host.emitExpr(counted.start); + const startInteger = exactInteger(host, startValue, counted.start); + let start: string; + if (startInteger) start = widenInteger(host, startInteger); + else { + // The counted-loop safety test above proved an exact integer. + start = B.tmp(); + B.line(`${start} = fptosi double ${startValue.name} to i64`); + } + const limit = emitCountedLoopLimit(host, counted); + const step = Math.abs(counted.step); + const edge = B.tmp(); + B.line(`${edge} = add i64 ${limit}, ${counted.step > 0 ? -1 : 1}`); + let last = edge; + if (step !== 1) { + const span = B.tmp(), + whole = B.tmp(), + covered = B.tmp(); + last = B.tmp(); + B.line( + counted.step > 0 + ? `${span} = sub i64 ${edge}, ${start}` + : `${span} = sub i64 ${start}, ${edge}`, + ); + B.line(`${whole} = sdiv i64 ${span}, ${step}`); + B.line(`${covered} = mul i64 ${whole}, ${step}`); + B.line(`${last} = ${counted.step > 0 ? "add" : "sub"} i64 ${start}, ${covered}`); + } + const low = counted.step > 0 ? start : last; + const high = counted.step > 0 ? last : start; + if (version.counterBound !== null) { + const above = B.tmp(), + below = B.tmp(); + B.line(`${above} = icmp sge i64 ${low}, ${-version.counterBound}`); + B.line(`${below} = icmp sle i64 ${high}, ${version.counterBound}`); + conditions.push(above, below); + } + for (const check of version.affine) { + let base = "0"; + for (const term of check.terms) { + const value = host.emitExpr(term.expr); + const integer = exactInteger(host, value, term.expr); + if (!integer) { + // Unreachable: every term has a proven range. Stay checked. + B.br(slow); + B.startBlock(B.newLabel("bytes.version.dead")); + return; + } + const wide = widenInteger(host, integer); + const sum = B.tmp(); + B.line(`${sum} = ${term.sign > 0 ? "add" : "sub"} i64 ${base}, ${wide}`); + base = sum; + } + // Products wrap only for a loop with no iteration: within the + // iteration space every scaled counter is a proven safe integer. + const scaledLow = scaleCounter(host, low, check.scale), + scaledHigh = scaleCounter(host, high, check.scale); + const first = B.tmp(), + final = B.tmp(), + nonNegative = B.tmp(), + below = B.tmp(), + both = B.tmp(); + B.line(`${first} = add i64 ${check.scale > 0 ? scaledLow : scaledHigh}, ${base}`); + B.line(`${final} = add i64 ${check.scale > 0 ? scaledHigh : scaledLow}, ${base}`); + const firstOffset = B.tmp(), + lastEnd = B.tmp(); + B.line(`${firstOffset} = add i64 ${first}, ${check.minOffset}`); + B.line(`${lastEnd} = add i64 ${final}, ${check.maxEnd}`); + B.line(`${nonNegative} = icmp sge i64 ${firstOffset}, 0`); + // Lengths are at most 2^53 elements: a signed comparison is exact. + B.line(`${below} = icmp slt i64 ${lastEnd}, ${lengths[check.receiver]}`); + B.line(`${both} = and i1 ${nonNegative}, ${below}`); + conditions.push(both); + } + } + let valid = "true"; + for (const cond of conditions) { + if (valid === "true") valid = cond; + else { + const all = B.tmp(); + B.line(`${all} = and i1 ${valid}, ${cond}`); + valid = all; + } + } + if (valid === "true") B.br(fast); + else B.condBr(valid, fast, slow); +} + +function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string { + if (scale === 1) return value; + const scaled = host.B.tmp(); + host.B.line(`${scaled} = mul i64 ${value}, ${scale}`); + return scaled; +} diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 03e0ed4f3..36fc63e1f 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -42,3 +42,54 @@ export function emitCountedLoopLimit( B.line(`${limit} = add nsw i64 ${integer}, ${ascending ? 1 : -1}`); return limit; } + +/** The run-time test of a guarded counted loop: an unproven limit is within + * the integer form's finite range and an unproven start is an exact safe + * integer. Returns `true` when nothing needs testing. */ +export function emitCountedLoopSafety( + host: LlvmEmitterContext, + loop: IntegerCountedForLoop, +): string { + const B = host.B; + let safe = "true"; + if (loop.limitGuarded) { + const bound = host.emitExpr(loop.limit); + safe = B.tmp(); + B.line( + `${safe} = fcmp ${loop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(loop.guardLimit)}`, + ); + } + if (loop.startGuarded) { + // An exact integer of at most 2^53 - 1 in magnitude, and not -0 + // (the bit patterns of the value and its round trip agree). + // NaN and infinities fail the ordered range tests. + const start = host.emitExpr(loop.start); + const low = B.tmp(), + high = B.tmp(), + raw = B.tmp(), + wide = B.tmp(), + back = B.tmp(), + bits = B.tmp(), + backBits = B.tmp(), + exact = B.tmp(), + inRange = B.tmp(), + ok = B.tmp(); + B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(-Number.MAX_SAFE_INTEGER)}`); + B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`); + B.line(`${inRange} = and i1 ${low}, ${high}`); + B.line(`${raw} = fptosi double ${start.name} to i64`); + B.line(`${wide} = freeze i64 ${raw}`); + B.line(`${back} = sitofp i64 ${wide} to double`); + B.line(`${bits} = bitcast double ${start.name} to i64`); + B.line(`${backBits} = bitcast double ${back} to i64`); + B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`); + B.line(`${ok} = and i1 ${inRange}, ${exact}`); + if (safe === "true") safe = ok; + else { + const both = B.tmp(); + B.line(`${both} = and i1 ${safe}, ${ok}`); + safe = both; + } + } + return safe; +} diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2cccda19c..8c83638a5 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -54,8 +54,10 @@ import { matchIntegerArrayForLoop, matchIntegerBytesForLoop, matchIntegerCountedForLoop, + type IntegerCountedForLoop, } from "../../ir/integer-loops.js"; -import { emitCountedLoopLimit } from "./counted-loops.js"; +import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js"; +import { emitBytesLoopVersionGuard, matchBytesLoopVersion } from "./bytes-loop-versions.js"; import { emitSafeIntegerCheck, emitSpeculativeInteger, @@ -66,6 +68,7 @@ import { } from "./speculative-integers.js"; import { emitLiteralSwitch } from "./switch-dispatch.js"; import { findBytesBounds } from "./bytes-bounds.js"; +import { globalSlotAlias } from "./bytes-alias.js"; import { emitByteWindowGuard, findInitializedByteLoopBindings, @@ -1065,6 +1068,14 @@ export class LlEmitter { wrappingSums = new Set(); private byteWindowsEnabled = true; private byteWindowEntries: ReturnType = new Map(); + /** Typed-array loop versioning (bytes-loop-versions.ts) for the current + * function: off in debug and suspending functions and inside a copy. */ + private bytesLoopVersionsEnabled = false; + private bytesLoopEntries: ReturnType | null = null; + private bytesLoopFunction: IrFunction | null = null; + /** Counted loops whose integer-form safety test an enclosing version + * guard already passed. */ + private readonly provenCountedGuards = new Set(); bytesBounds: ReadonlySet = new Set(); integerViews = new Map(); /** Module globals the current function owns outright (initializer @@ -6161,6 +6172,34 @@ export class LlEmitter { }; } + private matchBytesLoopVersion( + s: IrStmt & { kind: "for" }, + counted: IntegerCountedForLoop | null, + ): ReturnType { + const fn = this.bytesLoopFunction; + if (!fn) return null; + this.bytesLoopEntries ??= findInitializedByteLoopBindings(fn); + const initialized = this.bytesLoopEntries.get(s); + if (!initialized) return null; + const invariantGlobals = new Set(this.stableGlobals); + for (const global of this.initializerBindings.get(fn.name) ?? []) + invariantGlobals.add(global.id); + return matchBytesLoopVersion(s, counted, { + locals: this.currentLocals, + invariantGlobals, + captured: this.captureIds, + ranges: this.integerRanges, + initialized, + proven: this.bytesBounds, + }); + } + + /** The alias tag of a whole-slot load of a binding (module globals only). */ + slotAlias(id: string): string { + const binding = this.binding(id); + return binding.kind === "global" ? globalSlotAlias(this, id, binding.type.kind) : ""; + } + private emitDebugLocal(local: IrLocal, slot: string, arg = 0, captured = false): void { const debug = this.debug?.local(local, this.debugScope, arg, captured); if (!debug) return; @@ -6712,6 +6751,9 @@ export class LlEmitter { this.finalFieldBorrows = !fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0; this.byteWindowsEnabled = this.countedLoopsEnabled; + this.bytesLoopVersionsEnabled = this.countedLoopsEnabled; + this.bytesLoopEntries = null; + this.bytesLoopFunction = fn; this.byteWindowEntries = this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes") ? findInitializedByteLoopBindings(fn) @@ -8670,48 +8712,40 @@ export class LlEmitter { B.startBlock(done); break; } + const version = this.bytesLoopVersionsEnabled + ? this.matchBytesLoopVersion(s, countedLoop) + : null; + if (version) { + const fast = B.newLabel("bytes.version.fast"), + slow = B.newLabel("bytes.version.slow"), + done = B.newLabel("bytes.version.done"); + emitBytesLoopVersionGuard(this, version, fast, slow); + const originalBounds = this.bytesBounds, + originalRanges = this.integerRanges; + this.bytesLoopVersionsEnabled = false; + try { + B.startBlock(slow); + this.emitStmt(s); + B.br(done); + B.startBlock(fast); + this.bytesBounds = new Set([...originalBounds, ...version.bounds]); + if (version.ranges.size > 0) + this.integerRanges = new Map([...originalRanges, ...version.ranges]); + if (version.counted?.guarded) this.provenCountedGuards.add(s); + this.emitStmt(s); + B.br(done); + } finally { + this.bytesBounds = originalBounds; + this.integerRanges = originalRanges; + this.provenCountedGuards.delete(s); + this.bytesLoopVersionsEnabled = true; + } + B.startBlock(done); + break; + } let countedJoin: string | null = null; - if (countedLoop?.guarded) { - let safe = "true"; - if (countedLoop.limitGuarded) { - const bound = this.emitExpr(countedLoop.limit); - safe = B.tmp(); - B.line( - `${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`, - ); - } - if (countedLoop.startGuarded) { - // An exact integer of at most 2^53 - 1 in magnitude, and not -0 - // (the bit patterns of the value and its round trip agree). - // NaN and infinities fail the ordered range tests. - const start = this.emitExpr(countedLoop.start); - const low = B.tmp(), - high = B.tmp(), - raw = B.tmp(), - wide = B.tmp(), - back = B.tmp(), - bits = B.tmp(), - backBits = B.tmp(), - exact = B.tmp(), - inRange = B.tmp(), - ok = B.tmp(); - B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(-Number.MAX_SAFE_INTEGER)}`); - B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`); - B.line(`${inRange} = and i1 ${low}, ${high}`); - B.line(`${raw} = fptosi double ${start.name} to i64`); - B.line(`${wide} = freeze i64 ${raw}`); - B.line(`${back} = sitofp i64 ${wide} to double`); - B.line(`${bits} = bitcast double ${start.name} to i64`); - B.line(`${backBits} = bitcast double ${back} to i64`); - B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`); - B.line(`${ok} = and i1 ${inRange}, ${exact}`); - if (safe === "true") safe = ok; - else { - const both = B.tmp(); - B.line(`${both} = and i1 ${safe}, ${ok}`); - safe = both; - } - } + if (countedLoop?.guarded && !this.provenCountedGuards.has(s)) { + const safe = emitCountedLoopSafety(this, countedLoop); const fast = B.newLabel("counted.fast"); const slow = B.newLabel("counted.slow"); countedJoin = B.newLabel("counted.done"); @@ -8818,7 +8852,7 @@ export class LlEmitter { B.line( `${lenPtr} = getelementptr inbounds %${integerArrayLoop ? "ScrArr" : "ScrBytes"}, ptr ${receiver.name}, i64 0, i32 1`, ); - if (integerArrayLoop) this.markMemoryPointer(lenPtr, "array:header"); + this.markMemoryPointer(lenPtr, integerArrayLoop ? "array:header" : "bytes:header"); B.line( `${len} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`, ); @@ -10309,7 +10343,7 @@ export class LlEmitter { } if (binding.kind === "global") this.checkGlobalTdz(e.localId); const value = this.B.tmp(); - this.B.line(`${value} = load ptr, ptr ${binding.slot}`); + this.B.line(`${value} = load ptr, ptr ${binding.slot}${this.slotAlias(e.localId)}`); return { name: value, type: e.type }; } if (e.kind === "dynCheck" && e.exact) return this.emitExactCapsuleObject(e.value, e.type); diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index ac025df09..485b7a6b3 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -8,6 +8,7 @@ import { exactInteger, widenInteger } from "./integer-values.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; import { emitByteNumber } from "./byte-numbers.js"; import { integerOrNaNRange } from "./wrapping-sums.js"; +import { bytesElementAlias, bytesHeaderAlias } from "./bytes-alias.js"; const BYTES_NUM_KIND: Record = { u8: { kind: 0, le: false }, @@ -93,10 +94,7 @@ export function emitBytesIndex( B.line(`${narrow} = trunc i64 ${wide} to ${host.sizeType}`); return narrow; } - const lenPtr = B.tmp(); - const len = B.tmp(); - B.line(`${lenPtr} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 1`); - B.line(`${len} = load ${host.sizeType}, ptr ${lenPtr}`); + const len = emitBytesHeaderLength(host, receiver); if (integer) { const wideIndex = widenInteger(host, integer); const wideLength = host.sizeType === "i64" ? len : B.tmp(); @@ -145,11 +143,20 @@ export function emitBytesIndex( return idx; } +/** The element count of a typed array, in the target's size type. */ +export function emitBytesHeaderLength(host: LlvmEmitterContext, receiver: string): string { + const p = host.B.tmp(); + const len = host.B.tmp(); + host.B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 1`); + host.B.line(`${len} = load ${host.sizeType}, ptr ${p}${bytesHeaderAlias(host)}`); + return len; +} + export function emitBytesData(host: LlvmEmitterContext, receiver: string): string { const p = host.B.tmp(); const data = host.B.tmp(); host.B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 3`); - host.B.line(`${data} = load ptr, ptr ${p}`); + host.B.line(`${data} = load ptr, ptr ${p}${bytesHeaderAlias(host)}`); return data; } @@ -160,10 +167,7 @@ export function emitBytesLength( bytes: boolean, ): LlValue { const B = host.B; - const p = B.tmp(); - const len = B.tmp(); - B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 1`); - B.line(`${len} = load ${host.sizeType}, ptr ${p}`); + const len = emitBytesHeaderLength(host, receiver); const size = BYTES_ELEMENT_SIZE[elem]; const count = bytes && size !== 1 ? B.tmp() : len; if (count !== len) B.line(`${count} = shl ${host.sizeType} ${len}, ${Math.log2(size)}`); @@ -187,7 +191,7 @@ function emitSharedBytesSplit(host: LlvmEmitterContext, receiver: string): strin isShared = B.tmp(), expected = B.tmp(); B.line(`${slot} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 9`); - B.line(`${storage} = load ptr, ptr ${slot}`); + B.line(`${storage} = load ptr, ptr ${slot}${bytesHeaderAlias(host)}`); B.line(`${isShared} = icmp ne ptr ${storage}, null`); B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${isShared}, i1 false)`); const shared = B.newLabel("bytes.shared"), @@ -257,9 +261,15 @@ function emitBytesLoad( inBounds: boolean, invalid: string | undefined, ): LlValue { + if (host.mod.workers) { + const idx = emitBytesIndex(host, receiver, index, expr, invalid, inBounds); + return emitSharedOrLocalLoad(host, elem, receiver, idx); + } + // The header is immutable, so the data pointer loads beside the length, + // ahead of the bounds branch: an unconditional load LICM can hoist. + const data = host.emitBytesData(receiver); const idx = emitBytesIndex(host, receiver, index, expr, invalid, inBounds); - if (host.mod.workers) return emitSharedOrLocalLoad(host, elem, receiver, idx); - return emitBytesLoadAt(host, elem, receiver, idx); + return emitBytesLoadAt(host, elem, data, idx); } /** Worker programs: the element at a checked index of a view that may be @@ -285,7 +295,7 @@ function emitSharedOrLocalLoad( const sharedDone = B.newLabel("bytes.shared.done"); B.br(sharedDone); B.startBlock(local); - const own = emitBytesLoadAt(host, elem, receiver, idx); + const own = emitBytesLoadAt(host, elem, host.emitBytesData(receiver), idx); const localDone = B.newLabel("bytes.local.done"); B.br(localDone); B.startBlock(localDone); @@ -306,15 +316,15 @@ function emitSharedOrLocalLoad( return { name: out, type: F64, uint32: u32 }; } -/** The element at a checked index of a thread-owned view. */ +/** The element at a checked index of a thread-owned view, given its data + * pointer. */ function emitBytesLoadAt( host: LlvmEmitterContext, elem: IrBytesElem, - receiver: string, + data: string, idx: string, ): LlValue { const B = host.B; - const data = host.emitBytesData(receiver); const p = B.tmp(); if (elem === "f16") { // Half-precision bits widen exactly; the runtime converts them. @@ -322,7 +332,7 @@ function emitBytesLoadAt( const raw = B.tmp(); const out = B.tmp(); B.line(`${p} = getelementptr inbounds i16, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`${raw} = load i16, ptr ${p}, align 1`); + B.line(`${raw} = load i16, ptr ${p}, align 1${bytesElementAlias(host)}`); B.line(`${out} = call double @scr_f16_to_f64(i16 zeroext ${raw})`); return { name: out, type: F64 }; } @@ -333,7 +343,7 @@ function emitBytesLoadAt( const wide = B.tmp(); const out = B.tmp(); B.line(`${p} = getelementptr inbounds i${bits}, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`${raw} = load i${bits}, ptr ${p}, align 1`); + B.line(`${raw} = load i${bits}, ptr ${p}, align 1${bytesElementAlias(host)}`); B.line(`${wide} = ${signed ? "sext" : "zext"} i${bits} ${raw} to i32`); B.line(`${out} = ${signed ? "sitofp" : "uitofp"} i32 ${wide} to double`); return { name: out, type: F64, uint32: wide }; @@ -342,20 +352,20 @@ function emitBytesLoadAt( const raw = B.tmp(); const out = B.tmp(); B.line(`${p} = getelementptr inbounds float, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`${raw} = load float, ptr ${p}, align 1`); + B.line(`${raw} = load float, ptr ${p}, align 1${bytesElementAlias(host)}`); B.line(`${out} = fpext float ${raw} to double`); return { name: out, type: F64 }; } if (elem === "f64") { const out = B.tmp(); B.line(`${p} = getelementptr inbounds double, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`${out} = load double, ptr ${p}, align 1`); + B.line(`${out} = load double, ptr ${p}, align 1${bytesElementAlias(host)}`); return { name: out, type: F64 }; } const raw = B.tmp(); const out = B.tmp(); B.line(`${p} = getelementptr inbounds i32, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`${raw} = load i32, ptr ${p}, align 1`); + B.line(`${raw} = load i32, ptr ${p}, align 1${bytesElementAlias(host)}`); B.line(`${out} = ${elem === "i32" ? "sitofp" : "uitofp"} i32 ${raw} to double`); return { name: out, type: F64, uint32: raw }; } @@ -465,6 +475,7 @@ export function emitBytesSet( const B = host.B; // Invalid integer-indexed writes are ignored after evaluating the RHS. const done = B.newLabel("bytes.store.done"); + const data = host.emitBytesData(receiver); const idx = emitBytesIndex(host, receiver, index, indexExpr, done, inBounds); if (host.mod.workers) { host.declare(`declare void @scr_bytes_shared_set(ptr, ${host.sizeType}, double)`); @@ -487,7 +498,6 @@ export function emitBytesSet( elem === "f16" || elem === "f32" || elem === "f64" ? null : host.emitToUint32(value.name, expr, value.uint32); - const data = host.emitBytesData(receiver); const p = B.tmp(); if (elem === "f16") { // One round-to-nearest-even step from the double, in the runtime. @@ -495,24 +505,24 @@ export function emitBytesSet( const narrowed = B.tmp(); B.line(`${narrowed} = call zeroext i16 @scr_f16_from_f64(double ${value.name})`); B.line(`${p} = getelementptr inbounds i16, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`store i16 ${narrowed}, ptr ${p}, align 1`); + B.line(`store i16 ${narrowed}, ptr ${p}, align 1${bytesElementAlias(host)}`); } else if (BYTES_ELEMENT_SIZE[elem] < 4) { const bits = BYTES_ELEMENT_SIZE[elem] * 8; const byte = B.tmp(); B.line(`${byte} = trunc i32 ${stored!} to i${bits}`); B.line(`${p} = getelementptr inbounds i${bits}, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`store i${bits} ${byte}, ptr ${p}, align 1`); + B.line(`store i${bits} ${byte}, ptr ${p}, align 1${bytesElementAlias(host)}`); } else if (elem === "f32") { const narrowed = B.tmp(); B.line(`${narrowed} = fptrunc double ${value.name} to float`); B.line(`${p} = getelementptr inbounds float, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`store float ${narrowed}, ptr ${p}, align 1`); + B.line(`store float ${narrowed}, ptr ${p}, align 1${bytesElementAlias(host)}`); } else if (elem === "f64") { B.line(`${p} = getelementptr inbounds double, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`store double ${value.name}, ptr ${p}, align 1`); + B.line(`store double ${value.name}, ptr ${p}, align 1${bytesElementAlias(host)}`); } else { B.line(`${p} = getelementptr inbounds i32, ptr ${data}, ${host.sizeType} ${idx}`); - B.line(`store i32 ${stored!}, ptr ${p}, align 1`); + B.line(`store i32 ${stored!}, ptr ${p}, align 1${bytesElementAlias(host)}`); } B.br(done); B.startBlock(done); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 9c77690f8..470477d44 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -526,7 +526,7 @@ export function emitStableReceiver( if (b.kind !== "boxed") { if (b.kind === "global") host.checkGlobalTdz(receiver.localId); const value = host.B.tmp(); - host.B.line(`${value} = load ptr, ptr ${b.slot}`); + host.B.line(`${value} = load ptr, ptr ${b.slot}${host.slotAlias(receiver.localId)}`); return { name: value, type: receiver.type }; } } diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index aaae4c35c..7ad649f2d 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -107,7 +107,7 @@ export function emitLiteralExpr( return host.own({ name: v, type: e.type }); } const t = B.tmp(); - B.line(`${t} = load ${host.llType(b.type)}, ptr ${b.slot}`); + B.line(`${t} = load ${host.llType(b.type)}, ptr ${b.slot}${host.slotAlias(e.localId)}`); const integerView = host.integerViews.get(e.localId); if (integerView !== undefined) { // Snapshot both representations before later operands can write diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5467-typed-array-loop-versions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5467-typed-array-loop-versions.ts.json new file mode 100644 index 000000000..a4be59696 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5467-typed-array-loop-versions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5467-typed-array-loop-versions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5468-typed-array-loop-aliasing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5468-typed-array-loop-aliasing.ts.json new file mode 100644 index 000000000..517cbab4c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5468-typed-array-loop-aliasing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5468-typed-array-loop-aliasing.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5467-typed-array-loop-versions.ts b/tests/corpus/5467-typed-array-loop-versions.ts new file mode 100644 index 000000000..62848ae43 --- /dev/null +++ b/tests/corpus/5467-typed-array-loop-versions.ts @@ -0,0 +1,238 @@ +// Typed-array loops whose accesses are proved in bounds by one test before +// the loop run without per-element checks; every other entry runs the +// checked loop. Both must keep JavaScript's exact out-of-bounds behavior: +// reads of missing elements are undefined (NaN in arithmetic), writes are +// ignored, DataView accesses throw at the failing iteration, and +// fractional, negative and NaN indices never touch storage. + +const table: Int32Array = new Int32Array(256); +for (let n = 0; n < 256; n++) table[n] = (n * 2654435761) | 0; +const shortTable: Int32Array = new Int32Array(100); +for (let n = 0; n < 100; n++) shortTable[n] = n + 1; + +function masked(t: Int32Array, data: Uint8Array): number { + let h = 0; + for (let i = 0; i < data.length; i++) h = (h ^ t[(h ^ data[i]) & 0xff]) | 0; + return h; +} + +function maskedWrite(t: Int32Array, count: number): void { + for (let i = 0; i < count; i++) t[(i * 7) & 0xff] = i; +} + +const data = new Uint8Array(64); +for (let i = 0; i < data.length; i++) data[i] = (i * 37 + 11) & 0xff; +console.log("masked full", masked(table, data)); +console.log("masked short", masked(shortTable, data)); +const writable = new Int32Array(200); +maskedWrite(writable, 300); +console.log("masked write", writable[0], writable[7], writable[199], writable.length); + +// Counter plus an invariant base, in bounds and running off either end. +function shifted(src: Float64Array, out: Float64Array, base: number, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) { + out[base + i] = src[i] * 2; + sum += src[base + i - 1]; + } + return sum; +} + +const src = new Float64Array(8); +for (let i = 0; i < 8; i++) src[i] = i + 0.25; +for (const [base, n] of [ + [1, 4], + [0, 8], + [5, 8], + [-3, 6], + [2, 0], + [3, 2.5], +]) { + const out = new Float64Array(8); + const sum = shifted(src, out, base, n); + console.log("shifted", base, n, sum, Array.from(out).join(",")); +} + +// Descending loops, including ones that walk below zero. +function descending(a: Uint16Array, low: number): number { + let sum = 0; + for (let i = a.length - 1; i >= low; i--) { + a[i] = a[i] + 1000; + sum += a[i]; + } + return sum; +} +const u16 = new Uint16Array([65000, 1, 2, 3]); +console.log("descending", descending(u16, 0), Array.from(u16).join(",")); +console.log("descending below", descending(u16, -2), Array.from(u16).join(",")); + +// Fractional starts and offsets never name an element. +function fractional(a: Float32Array): string { + const seen: string[] = []; + for (let i = 0.5; i < 3; i++) seen.push(String(a[i])); + for (let i = 0; i < 3; i++) { + a[i + 0.5] = 99; + seen.push(String(a[i] + a[i - 0.5])); + } + return seen.join(" "); +} +console.log("fractional", fractional(new Float32Array([1.5, 2.5, 3.5]))); + +function nanIndex(a: Int8Array, offset: number): number { + let sum = 0; + for (let i = 0; i < a.length; i++) { + a[i + offset] = 120; + sum += a[i + offset] | 0; + } + return sum; +} +const i8 = new Int8Array([1, 2, 3]); +console.log("nan index", nanIndex(i8, NaN), nanIndex(i8, -0), Array.from(i8).join(",")); + +// Unproven limits keep the integer loop's guard: huge, infinite and NaN. +function limits(a: Uint8Array, limit: number): number { + let sum = 0; + for (let i = 0; i < limit; i++) { + if (i >= a.length + 2) break; + sum += a[i] | 0; + a[i] = i; + } + return sum; +} +const lim = new Uint8Array([5, 6, 7]); +console.log("limits", limits(lim, 2), limits(lim, 1e300), limits(lim, Infinity), limits(lim, NaN)); + +// A DataView access past the end throws after the earlier iterations ran. +const viewProgress: number[] = [0]; +function viewLoop(view: DataView, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) { + sum += view.getUint32(i * 4, true) + view.getUint8(i * 4 + 3); + viewProgress[0] = sum; + } + return sum; +} +function viewSum(view: DataView, n: number): number { + viewProgress[0] = -1; + try { + return viewLoop(view, n); + } catch (e) { + console.log("view throw", (e as Error).name, viewProgress[0]); + } + return viewProgress[0]; +} +const dvBytes = new Uint8Array(16); +for (let i = 0; i < 16; i++) dvBytes[i] = i * 3; +const view = new DataView(dvBytes.buffer); +console.log("view ok", viewSum(view, 4)); +console.log("view partial", viewSum(view, 6)); + +// Strided loops: the last counter value of a non-unit step is exact, so a +// record that ends at the last element still takes the unchecked loop and +// a partial trailing record does not. +function quads(words: Uint16Array): number { + let sum = 0; + for (let i = 0; i < words.length; i += 4) sum += words[i] + words[i + 3] * 2; + return sum; +} +console.log("quads", quads(new Uint16Array([1, 2, 3, 4, 5, 6, 7, 8]))); +console.log("quads partial", quads(new Uint16Array([1, 2, 3, 4, 5, 6]))); +function backTriples(words: Uint16Array): number { + let sum = 0; + for (let i = words.length - 1; i >= 0; i -= 3) sum = sum * 3 + words[i] + (words[i - 2] | 0); + return sum; +} +console.log("back triples", backTriples(new Uint16Array([1, 2, 3, 4, 5, 6]))); +console.log("back triples partial", backTriples(new Uint16Array([1, 2, 3, 4, 5, 6, 7]))); +function scaled(words: Uint16Array, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) sum += words[i * 2] - (words[(i + 1) * 2 - 1] | 0) + (words[9 - i] | 0); + return sum; +} +const scaledWords = new Uint16Array(10); +for (let i = 0; i < 10; i++) scaledWords[i] = i * i; +console.log("scaled", scaled(scaledWords, 5), scaled(scaledWords, 6), scaled(scaledWords, 11)); +// A negative zero index names element 0 and stays -0 as a number. +function negativeZero(words: Uint16Array, n: number): string { + const seen: string[] = []; + for (let i = 0; i < n; i++) { + const k = i * -1; + const m = (i - 1) * 2; + seen.push(`${words[k]}:${Object.is(k, -0)}:${words[m] | 0}:${Object.is(m, -0)}`); + } + return seen.join(" "); +} +console.log("negative zero", negativeZero(scaledWords, 3)); + +// Clamped and wrapping element types on the unchecked path. +function convert(c: Uint8ClampedArray, s: Int16Array): string { + for (let i = 0; i < 4; i++) { + c[i] = i * 100 - 50.5; + s[i] = i * 20000; + } + return Array.from(c).join(",") + " " + Array.from(s).join(","); +} +console.log("convert", convert(new Uint8ClampedArray(4), new Int16Array(4))); + +// Module receivers stay correct across calls in the loop body, including a +// call that writes the same arrays. +const left: Float64Array = new Float64Array(16); +const right: Float64Array = new Float64Array(16); +function poke(k: number): number { + right[k] = right[k] + 1; + return k; +} +for (let i = 0; i < 16; i++) left[i] = i; +let crossed = 0; +for (let i = 0; i < 16; i++) { + right[i] = left[i] * 3; + crossed += right[poke(i)]; +} +console.log("module receivers", crossed, right[0], right[15]); + +// A body that reassigns its receiver keeps reading the current binding. +function rebinding(first: Uint8Array, second: Uint8Array): number { + let current = first; + let sum = 0; + for (let i = 0; i < 4; i++) { + sum += current[i] | 0; + if (i === 1) current = second; + } + return sum; +} +console.log("rebinding", rebinding(new Uint8Array([1, 2, 3, 4]), new Uint8Array([10, 20]))); + +// A throwing body leaves the writes of earlier iterations in place. +function failing(a: Uint32Array, at: number): void { + for (let i = 0; i < a.length; i++) { + if (i === at) throw new Error("stop at " + i); + a[i] = 4294967295 - i; + } +} +const u32 = new Uint32Array(5); +try { + failing(u32, 3); +} catch (e) { + console.log((e as Error).message, Array.from(u32).join(",")); +} + +// A matrix product over module-level constant arrays. +const N = 6; +const ma: Float64Array = new Float64Array(N * N); +const mb: Float64Array = new Float64Array(N * N); +const mc: Float64Array = new Float64Array(N * N); +for (let i = 0; i < N * N; i++) { + ma[i] = (i % 7) - 3; + mb[i] = (i % 5) * 0.5; +} +for (let i = 0; i < N; i++) { + for (let k = 0; k < N; k++) { + const aik = ma[i * N + k]; + const kOff = k * N; + const iOff = i * N; + for (let j = 0; j < N; j++) mc[iOff + j] += aik * mb[kOff + j]; + } +} +let trace = 0; +for (let i = 0; i < N; i++) trace += mc[i * N + i]; +console.log("matmul", trace, mc[1], mc[N * N - 1]); diff --git a/tests/corpus/5468-typed-array-loop-aliasing.ts b/tests/corpus/5468-typed-array-loop-aliasing.ts new file mode 100644 index 000000000..befcc8227 --- /dev/null +++ b/tests/corpus/5468-typed-array-loop-aliasing.ts @@ -0,0 +1,87 @@ +// Element stores may alias any other view of the same buffer, whatever its +// element type, offset or length, but never a typed array's own length and +// data pointer. Loops that write through one view and read through another +// must observe every store in program order, also when the loop runs +// without per-element bounds checks. + +const buffer = new ArrayBuffer(64); +const f64 = new Float64Array(buffer); +const u8 = new Uint8Array(buffer); +const i32 = new Int32Array(buffer, 8, 8); + +// Writes through Float64Array observed through a Uint8Array in the same +// iteration. +let topBytes = 0; +for (let i = 0; i < f64.length; i++) { + f64[i] = -(i + 1) * 1.5; + topBytes += u8[i * 8 + 7]; +} +console.log("f64 then u8", topBytes, u8[7], u8[63]); + +// Byte stores observed through an overlapping Int32Array window. +let words = 0; +for (let i = 0; i < 32; i++) { + u8[8 + i] = i + 1; + words = (words + i32[i >> 2]) | 0; +} +console.log("u8 then i32", words, i32[0], i32[7]); + +// A shifted subarray reads what the previous iteration wrote: a prefix +// recurrence that any reordering or vector widening would break. +const chain = new Uint8Array(40); +chain[0] = 1; +const ahead = chain.subarray(1); +for (let i = 0; i < ahead.length; i++) ahead[i] = chain[i] + 2; +console.log("recurrence", chain[0], chain[1], chain[20], chain[39]); + +// Overlapping copies in both directions. +const values = new Int16Array(12); +for (let i = 0; i < values.length; i++) values[i] = i * 10; +const lower = values.subarray(0, 11); +const upper = values.subarray(1); +for (let i = 0; i < lower.length; i++) lower[i] = upper[i]; +console.log("copy down", Array.from(values).join(",")); +for (let i = 0; i < values.length; i++) values[i] = i * 10; +for (let i = 0; i < lower.length; i++) upper[i] = lower[i]; +console.log("copy up", Array.from(values).join(",")); + +// Same-array accumulation with a lagging read. +function lagged(a: Float64Array, lag: number): number { + for (let i = lag; i < a.length; i++) a[i] = a[i] + a[i - lag]; + return a[a.length - 1]; +} +const fib = new Float64Array(30); +fib[0] = 1; +fib[1] = 1; +console.log("lagged", lagged(fib, 1), lagged(fib, 2)); + +// Two parameters that are the same array, and a destination that aliases +// part of its own source. +function axpy(alpha: number, x: Float64Array, y: Float64Array, n: number): void { + for (let i = 0; i < n; i++) y[i] = y[i] + alpha * x[i]; +} +const same = new Float64Array([1, 2, 3, 4, 5, 6, 7, 8]); +axpy(2, same, same, same.length); +console.log("axpy same", Array.from(same).join(",")); +const whole = new Float64Array([1, 2, 3, 4, 5, 6, 7, 8]); +axpy(1, whole, whole.subarray(2), 6); +console.log("axpy shifted", Array.from(whole).join(",")); + +// Element stores never change a length or a module binding, even when a +// float store writes the bit pattern of a small length. +const meta: Float64Array = new Float64Array(4); +const lengths: number[] = []; +for (let i = 0; i < 4; i++) { + meta[i] = 5e-324 * (i + 1); + lengths.push(meta.length); +} +console.log("lengths", lengths.join(","), meta[3] > 0); + +// Global numbers next to typed-array stores keep their values. +let scale = 3; +const scaled: Float32Array = new Float32Array(6); +for (let i = 0; i < scaled.length; i++) { + scaled[i] = scale * i; + if (i === 2) scale = 0.5; +} +console.log("scaled", Array.from(scaled).join(",")); From 0cf44e9265db957ed63c4177bbb636653e251fae Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 14 Oct 2026 17:02:34 -0700 Subject: [PATCH 02/85] Version typed-array loops that contain nested loops An outer loop's statically ranged accesses (the sieve's flags[i]) are proved once per entry even when its body holds an inner loop. Versions nest at most three deep and never inside their own copies, and fixed checks only cover lengths a buffer can have, so an outer loop never pays for a guard on an inner loop's unbounded counter. --- .../src/backend/llvm/bytes-loop-versions.ts | 21 +++++++++---------- packages/compiler/src/backend/llvm/emitter.ts | 19 ++++++++++++----- .../corpus/5467-typed-array-loop-versions.ts | 15 +++++++++++++ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index d3a9b2042..6f30074f6 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -64,6 +64,7 @@ export interface BytesLoopFacts { } const MAX_COST = 400; +const MAX_FIXED_END = 2 ** 32; const MAX_CHECKS = 8; const MAX_OFFSET = 2 ** 31; const SAFE = Number.MAX_SAFE_INTEGER; @@ -81,9 +82,10 @@ const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]) * and last values in bounds, such as `out[base + i]` for `i < n` or * `view.getUint32(i * 4)`. * - * Only innermost synchronous loops are versioned, so the body is emitted - * twice at most. The checked loop runs whenever the guard fails, and keeps - * every out-of-bounds read, ignored write and NaN-index behavior. */ + * Only small synchronous loops are versioned (nested loops included), and + * the emitter bounds how deeply versions nest. The checked loop runs + * whenever the guard fails, and keeps every out-of-bounds read, ignored + * write and NaN-index behavior. */ export function matchBytesLoopVersion( loop: IrStmt & { kind: "for" }, counted: IntegerCountedForLoop | null, @@ -92,7 +94,6 @@ export function matchBytesLoopVersion( const written = new Set(); const temps = new Map(); let cost = 0; - let innermost = true; const scanExpr = (e: IrExpr): boolean => { if (++cost > MAX_COST) return false; if (e.kind === "assignExpr" || e.kind === "incDec") written.add(e.localId); @@ -101,12 +102,9 @@ export function matchBytesLoopVersion( const scanStmt = (s: IrStmt): boolean => { if (++cost > MAX_COST) return false; switch (s.kind) { - case "for": case "forOf": - case "while": - case "doWhile": - innermost = false; - return false; + written.add(s.localId); + break; case "varDecl": { if (temps.has(s.localId) || written.has(s.localId)) temps.delete(s.localId); else if (s.init) temps.set(s.localId, s.init); @@ -126,7 +124,6 @@ export function matchBytesLoopVersion( }; if ( !loop.body.every(scanStmt) || - !innermost || (loop.cond !== null && !scanExpr(loop.cond)) || (loop.update !== null && !scanStmt(loop.update)) ) @@ -339,7 +336,9 @@ export function matchBytesLoopVersion( const range = facts.ranges.get(index); if (!range) return; const end = range.max + width - 1; - if (range.min < 0 || !Number.isSafeInteger(end)) return; + // Only a length a buffer can have: an outer loop must not pay for a + // guard that bounds an inner loop's unbounded counter. + if (range.min < 0 || end >= MAX_FIXED_END) return; const receiver = receiverOf(ref); const group = fixed.get(receiver); if (group) { diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8c83638a5..a567a23b3 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -620,6 +620,9 @@ function mentionsLocal(e: IrExpr, localId: string): boolean { return found; } +/** Typed-array loop versions nest at most this deep (each doubles a body). */ +const MAX_BYTES_LOOP_VERSION_DEPTH = 3; + export class LlEmitter { localArrayReads = new Map(); private loopArrayBorrows: ReadonlySet = new Set(); @@ -1071,6 +1074,9 @@ export class LlEmitter { /** Typed-array loop versioning (bytes-loop-versions.ts) for the current * function: off in debug and suspending functions and inside a copy. */ private bytesLoopVersionsEnabled = false; + /** Loops being versioned around the statement being emitted: a loop is + * never versioned inside its own copies, and versions nest boundedly. */ + private readonly bytesLoopVersioning = new Set(); private bytesLoopEntries: ReturnType | null = null; private bytesLoopFunction: IrFunction | null = null; /** Counted loops whose integer-form safety test an enclosing version @@ -8712,9 +8718,12 @@ export class LlEmitter { B.startBlock(done); break; } - const version = this.bytesLoopVersionsEnabled - ? this.matchBytesLoopVersion(s, countedLoop) - : null; + const version = + this.bytesLoopVersionsEnabled && + this.bytesLoopVersioning.size < MAX_BYTES_LOOP_VERSION_DEPTH && + !this.bytesLoopVersioning.has(s) + ? this.matchBytesLoopVersion(s, countedLoop) + : null; if (version) { const fast = B.newLabel("bytes.version.fast"), slow = B.newLabel("bytes.version.slow"), @@ -8722,7 +8731,7 @@ export class LlEmitter { emitBytesLoopVersionGuard(this, version, fast, slow); const originalBounds = this.bytesBounds, originalRanges = this.integerRanges; - this.bytesLoopVersionsEnabled = false; + this.bytesLoopVersioning.add(s); try { B.startBlock(slow); this.emitStmt(s); @@ -8738,7 +8747,7 @@ export class LlEmitter { this.bytesBounds = originalBounds; this.integerRanges = originalRanges; this.provenCountedGuards.delete(s); - this.bytesLoopVersionsEnabled = true; + this.bytesLoopVersioning.delete(s); } B.startBlock(done); break; diff --git a/tests/corpus/5467-typed-array-loop-versions.ts b/tests/corpus/5467-typed-array-loop-versions.ts index 62848ae43..44a3da608 100644 --- a/tests/corpus/5467-typed-array-loop-versions.ts +++ b/tests/corpus/5467-typed-array-loop-versions.ts @@ -216,6 +216,21 @@ try { console.log((e as Error).message, Array.from(u32).join(",")); } +// An outer loop proved in bounds around an inner loop that is not. +function nested(grid: Int32Array, w: number, h: number): number { + let sum = 0; + for (let y = 0; y < h; y++) { + sum += grid[y & 7]; + for (let x = 0; x < w; x++) { + grid[y * w + x] = grid[y * w + x] + 1; + sum += grid[y * w + x] | 0; + } + } + return sum; +} +const grid = new Int32Array(16); +console.log("nested", nested(grid, 4, 4), nested(grid, 5, 4), nested(grid, 4, 5), grid.join(",")); + // A matrix product over module-level constant arrays. const N = 6; const ma: Float64Array = new Float64Array(N * N); From b0d2005b43bf42a4219bfd4a45bc2e7e57029c02 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 14 Oct 2026 17:22:41 -0700 Subject: [PATCH 03/85] Keep typed-array loop versioning within the self-hosted language subset The native bootstrap compiles the emitter with scriptc itself, which refuses comparisons of union-typed IR nodes and hand-written filter predicates. --- .../src/backend/llvm/bytes-loop-versions.ts | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index 6f30074f6..e890c3ee1 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -14,7 +14,12 @@ type VarRef = IrExpr & { kind: "varRef" }; /** One loop-invariant integer operand of an affine index. */ interface Term { expr: IrExpr; - sign: 1 | -1; + /** 1 or -1. */ + sign: number; +} + +interface KeyedTerm extends Term { + key: string; } /** Every access of `receiver` at a static index range ends below `end + 1`. */ @@ -28,7 +33,7 @@ interface FixedCheck { interface AffineCheck { receiver: number; scale: number; - terms: Term[]; + terms: KeyedTerm[]; minOffset: number; maxEnd: number; } @@ -195,7 +200,7 @@ export function matchBytesLoopVersion( interface Affine { /** The counter's coefficient; 0 when the value is loop-invariant. */ counter: number; - terms: (Term & { key: string })[]; + terms: KeyedTerm[]; offset: number; } // Every operand of an affine index is `counter * k + Σ terms + offset`. @@ -239,7 +244,7 @@ export function matchBytesLoopVersion( if (Math.abs(offset) > MAX_OFFSET) return null; const terms = [ ...left.terms, - ...right.terms.map((t) => ({ ...t, sign: (sign * t.sign) as 1 | -1 })), + ...right.terms.map((t) => ({ expr: t.expr, sign: sign * t.sign, key: t.key })), ]; const result = { counter: left.counter + sign * right.counter, terms, offset }; if (result.counter !== 0 || facts.ranges.get(e)) return result; @@ -248,12 +253,12 @@ export function matchBytesLoopVersion( // A positive constant multiple of the counter: `i * 4`, `4 * i`, // `(i + 1) * 8`. (A negative factor turns a zero into -0.) if (e.kind === "bin" && e.op === "*") { - const literal = - e.right.kind === "numLit" ? e.right : e.left.kind === "numLit" ? e.left : null; - const scaled = literal === e.right ? e.left : e.right; - const factor = literal?.value ?? 0; + const rightLiteral = e.right.kind === "numLit"; + const scaled = rightLiteral ? e.left : e.right; + const literal = rightLiteral ? e.right : e.left; + const factor = literal.kind === "numLit" ? literal.value : 0; if ( - literal && + literal.kind === "numLit" && scaled.type.kind === "f64" && Number.isSafeInteger(factor) && factor > 0 && @@ -372,17 +377,21 @@ export function matchBytesLoopVersion( bound = Math.min(bound, limit); return true; }); - const groups = [...fixed.values(), ...affineGroups].slice(0, MAX_CHECKS); - if (groups.length === 0) return null; + const fixedKept = [...fixed.values()].slice(0, MAX_CHECKS); + const affineKept = affineGroups.slice(0, MAX_CHECKS - fixedKept.length); + if (fixedKept.length === 0 && affineKept.length === 0) return null; const bounds = new Set(); const used = new Set(); const ranges = new Map(); const counterLow = Math.max(counted?.range.min ?? 0, -bound), counterHigh = Math.min(counted?.range.max ?? 0, bound); - for (const group of groups) { + for (const group of fixedKept) { + used.add(group.check.receiver); + for (const node of group.nodes) bounds.add(node); + } + for (const group of affineKept) { used.add(group.check.receiver); for (const node of group.nodes) bounds.add(node); - if (!("operands" in group)) continue; for (const operand of group.operands) { if (facts.ranges.get(operand)) continue; const shape = shapes.get(operand)!; @@ -398,8 +407,6 @@ export function matchBytesLoopVersion( if (counterLow <= counterHigh) ranges.set(operand, { min, max }); } } - const kept = groups.map((g) => g.check); - const affineKept = kept.filter((c): c is AffineCheck => "terms" in c); // Renumber the receivers the kept checks use. const order = [...used].sort((a, b) => a - b); const renumber = new Map(order.map((old, index) => [old, index])); @@ -410,10 +417,17 @@ export function matchBytesLoopVersion( return { bounds, receivers: order.map((index) => receivers[index]!), - fixed: kept - .filter((c): c is FixedCheck => !("terms" in c)) - .map((c) => ({ ...c, receiver: renumber.get(c.receiver)! })), - affine: affineKept.map((c) => ({ ...c, receiver: renumber.get(c.receiver)! })), + fixed: fixedKept.map((g) => ({ + receiver: renumber.get(g.check.receiver)!, + end: g.check.end, + })), + affine: affineKept.map((g) => ({ + receiver: renumber.get(g.check.receiver)!, + scale: g.check.scale, + terms: g.check.terms, + minOffset: g.check.minOffset, + maxEnd: g.check.maxEnd, + })), counted: affineKept.length > 0 ? counted : null, counterBound: needsBound ? bound : null, ranges, From b7c109dbe167db921b06ce7e15824e7c9da5e20f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 07:48:14 -0700 Subject: [PATCH 04/85] Cover Float16Array in typed-array loop versioning Float16Array elements are two bytes and convert through the runtime. Versioned loops bound them in elements against the element count, as for every other element type, and their loads and stores share the element alias category, since views of any type may share a buffer. DataView half-precision accesses are two bytes wide in the guard. The corpus covers unchecked and checked f16 loops, rounding in the unchecked copy, a masked index against a short array, a DataView loop that throws at an odd final offset, and f16 stores observed through integer views of the same buffer. --- .../backend/llvm/bytes-loop-versions.test.ts | 50 +++++++++++++++++++ .../corpus/5467-typed-array-loop-versions.ts | 45 +++++++++++++++++ .../corpus/5468-typed-array-loop-aliasing.ts | 20 ++++++++ 3 files changed, 115 insertions(+) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index d8ec036ff..3d21a8f82 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -208,3 +208,53 @@ test("emitted loops version the body and tag typed-array memory", () => { const header = /load ptr, ptr [^\n]*!tbaa !(\d+)/.exec(body)![1]; expect(elements).not.toBe(header); }); + +test("Float16Array accesses version in elements and share the element tag", () => { + const f16s: IrType = { kind: "bytes", elem: "f16" }; + const withHalves = (body: IrStmt[]): IrFunction => { + const f = fn(body, [local("half", f16s)]); + return { ...f, params: [...f.params, { localId: "half", name: "half", type: f16s }] }; + }; + // half[i] = src[i + 1]; out[i] = half[i] * 2 + const loop = counted([ + set(ref("half", f16s), ref("i"), get(ref("src", f64s), bin("+", ref("i"), num(1)))), + set(ref("out", f64s), ref("i"), bin("*", get(ref("half", f16s), ref("i")), num(2))), + ]); + const kernel = withHalves([loop]); + const version = match(kernel, loop)!; + expect(version.bounds.size).toBe(4); + expect(version.receivers.map((r) => r.ref)).toEqual([ + ref("half", f16s), + ref("src", f64s), + ref("out", f64s), + ]); + // Element offsets against the element count, whatever the element size. + expect(version.affine.map((c) => [c.scale, c.minOffset, c.maxEnd])).toEqual([ + [1, 0, 0], + [1, 1, 1], + [1, 0, 0], + ]); + + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "__main", + functions: [ + { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, + kernel, + ], + }; + const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0]; + const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0]; + expect(fast).toContain("call double @scr_f16_to_f64"); + expect(fast).toContain("call zeroext i16 @scr_f16_from_f64"); + expect(fast).not.toContain("bytes.index"); + const halfStore = /store i16 [^\n]*, align 1, !tbaa !(\d+)/.exec(fast)![1]; + const halfLoad = /load i16, ptr [^\n]*, align 1, !tbaa !(\d+)/.exec(fast)![1]; + const doubleStore = /store double [^\n]*, align 1, !tbaa !(\d+)/.exec(fast)![1]; + // Views of any element type may share a buffer: one element category. + expect(halfStore).toBe(doubleStore); + expect(halfLoad).toBe(doubleStore); + // The checked copy keeps its per-element tests. + expect(body.slice(0, body.indexOf(fast))).toContain("bytes.index.invalid"); +}); diff --git a/tests/corpus/5467-typed-array-loop-versions.ts b/tests/corpus/5467-typed-array-loop-versions.ts index 44a3da608..35a1da145 100644 --- a/tests/corpus/5467-typed-array-loop-versions.ts +++ b/tests/corpus/5467-typed-array-loop-versions.ts @@ -251,3 +251,48 @@ for (let i = 0; i < N; i++) { let trace = 0; for (let i = 0; i < N; i++) trace += mc[i * N + i]; console.log("matmul", trace, mc[1], mc[N * N - 1]); + +// Float16Array elements are two bytes; versioned loops bound them in +// elements, and stores round to nearest-even in the unchecked copy too. +function halves(src: Float16Array, out: Float16Array, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) { + out[i] = src[i + 1] * 1.001 + 0.1; + sum += src[i]; + } + return sum; +} +const halfSrc = new Float16Array(10); +for (let i = 0; i < halfSrc.length; i++) halfSrc[i] = i * 0.3 - 1; +const halfOut = new Float16Array(10); +console.log("f16 in bounds", halves(halfSrc, halfOut, 9), Array.from(halfOut).join(",")); +console.log("f16 past end", halves(halfSrc, halfOut, 12), Array.from(halfOut).join(",")); +const halfShort = new Float16Array(4); +console.log("f16 short out", halves(halfSrc, halfShort, 9), Array.from(halfShort).join(",")); +console.log("f16 rounding", halves(new Float16Array([65519, 2049, 1e-8, 0]), new Float16Array(3), 3)); + +function halfTable(t: Float16Array, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) sum += t[i & 7]; + return sum; +} +console.log("f16 masked", halfTable(new Float16Array([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]), 20)); +console.log("f16 masked short", halfTable(new Float16Array([0.5, 1.5, 2.5, 3.5, 4.5]), 20)); + +// DataView half-precision accesses are two bytes wide: the last odd offset +// throws. +function halfView(v: DataView, n: number): number { + let sum = 0; + for (let i = 0; i < n; i++) { + v.setFloat16(i * 2, i + 0.25, true); + sum += v.getFloat16(i * 2 + 1, false); + } + return sum; +} +const halfBytes = new DataView(new ArrayBuffer(11)); +console.log("f16 view", halfView(halfBytes, 4)); +try { + halfView(halfBytes, 6); +} catch (e) { + console.log((e as Error).message, new Uint8Array(halfBytes.buffer).join(",")); +} diff --git a/tests/corpus/5468-typed-array-loop-aliasing.ts b/tests/corpus/5468-typed-array-loop-aliasing.ts index befcc8227..019913d1c 100644 --- a/tests/corpus/5468-typed-array-loop-aliasing.ts +++ b/tests/corpus/5468-typed-array-loop-aliasing.ts @@ -85,3 +85,23 @@ for (let i = 0; i < scaled.length; i++) { if (i === 2) scale = 0.5; } console.log("scaled", Array.from(scaled).join(",")); + +// Half-precision stores observed through integer views of the same buffer +// in the same iteration, and byte stores observed through Float16Array. +const halfBuffer = new ArrayBuffer(32); +const f16 = new Float16Array(halfBuffer); +const u16 = new Uint16Array(halfBuffer); +const halfBytes = new Uint8Array(halfBuffer); +let bits = 0; +for (let i = 0; i < f16.length; i++) { + f16[i] = (i - 8) * 0.75; + bits = (bits + u16[i]) | 0; +} +console.log("f16 then u16", bits, u16[0], u16[15]); +let halfSum = 0; +for (let i = 0; i < f16.length; i++) { + halfBytes[i * 2 + 1] = 0x3c + (i & 3); + halfBytes[i * 2] = i; + halfSum += f16[i]; +} +console.log("u8 then f16", halfSum, f16[0], f16[15]); From 980036756cb2f24554da842840e52435e742edbb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 07:48:14 -0700 Subject: [PATCH 05/85] Skip typed-array loop versioning in bodies compiled without optimization Bodies with thousands of capture boxes compile optnone so code generation stays linear. There a versioned copy only adds code: nothing hoists the header loads or removes the checks it was made for. --- .../backend/llvm/bytes-loop-versions.test.ts | 18 ++++++++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 2 ++ 2 files changed, 20 insertions(+) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index 3d21a8f82..f33aefbf6 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -258,3 +258,21 @@ test("Float16Array accesses version in elements and share the element tag", () = // The checked copy keeps its per-element tests. expect(body.slice(0, body.indexOf(fast))).toContain("bytes.index.invalid"); }); + +test("bodies compiled without optimization keep one checked loop", () => { + const loop = counted([set(ref("out", f64s), ref("i"), get(ref("src", f64s), ref("i")))]); + const boxes = Array.from({ length: 512 }, (_, k) => local(`box${k}`, F64, true, { boxed: true })); + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "__main", + functions: [ + { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, + fn([loop], boxes), + ], + }; + const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0]; + expect(body.split("\n", 1)[0]).toContain("optnone"); + expect(body).not.toContain("bytes.version"); + expect(body).toContain("bytes.index.invalid"); +}); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a567a23b3..ad179bde4 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6995,6 +6995,8 @@ export class LlEmitter { for (const local of this.currentLocals.values()) if (local.boxed || this.captureIds.has(local.id)) slots.add(`%${mangleLocal(local.id)}`); B.volatileSlots = slots; + // Without optimization a versioned copy only adds code. + this.bytesLoopVersionsEnabled = false; } // Captured bindings come in through the environment — borrowed for the // whole call (the closure owns them): bound here, never released here. From 91c66e0ebabefd13fda03cb12fd1b6a7f40fd337 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 04:13:47 -0500 Subject: [PATCH 06/85] Support Object.freeze, Object.seal, Object.preventExtensions and Object.defineProperty on class instances Object.freeze, Object.seal and Object.preventExtensions accept a class instance whose static type names the class, and Object.isFrozen, Object.isSealed and Object.isExtensible answer for it as Node does. Object.defineProperty with a string-literal key changes the value and the writable, enumerable and configurable attributes of a declared field, with Node's validation and errors. Only hierarchies passed to these calls pay for them: they get a per-instance integrity word and an attribute word per redefined field, and each write to a declared field checks them first. A write to a read-only field throws Node's TypeError in strict mode code and leaves the field unchanged in sloppy mode code; a class field initializer on a non-extensible instance throws "Cannot define property x, object is not extensible". The instance's checked view carries the attributes, so Object.keys, JSON.stringify, util.inspect and getOwnPropertyDescriptor follow them. Readonly over a class maps to the class. Through an untyped value, freezing, sealing or preventing extensions of a class instance throws SC2020 instead of doing nothing. The runtime's "Cannot redefine property" and "Cannot add property" errors name the property, as Node does. --- .../builtin-apis/object-freezing.mdx | 2 +- .../src/backend/llvm/expr-stream-bridges.ts | 34 +- .../compiler/src/backend/llvm/lib-shared.ts | 6 + .../src/frontend/lowering/class-attributes.ts | 416 ++++++++++++++++++ .../frontend/lowering/class-descriptors.ts | 2 +- .../src/frontend/lowering/lower-calls.ts | 18 + .../src/frontend/lowering/lower-classes.ts | 2 + .../src/frontend/lowering/lower-exprs.ts | 2 + .../src/frontend/lowering/lower-inspect.ts | 11 +- .../src/frontend/lowering/lower-stmts.ts | 33 +- .../compiler/src/frontend/lowering/lowerer.ts | 2 + .../src/frontend/lowering/strict-mode.ts | 34 ++ packages/compiler/src/frontend/type-mapper.ts | 18 + packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 17 + packages/compiler/src/ir/ir.ts | 40 +- .../compiler/test/class-attributes.test.ts | 84 ++++ .../6004-class-instance-integrity.ts.json | 6 + ...5-class-instance-integrity-sloppy.cjs.json | 6 + packages/runtime/src/scr_dyn.h | 10 + packages/runtime/src/scr_json.c | 199 ++++++++- tests/corpus/6004-class-instance-integrity.ts | 169 +++++++ .../6005-class-instance-integrity-sloppy.cjs | 40 ++ tests/harness/corpus-costs.json | 4 + 24 files changed, 1114 insertions(+), 44 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-attributes.ts create mode 100644 packages/compiler/src/frontend/lowering/strict-mode.ts create mode 100644 packages/compiler/test/class-attributes.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json create mode 100644 tests/corpus/6004-class-instance-integrity.ts create mode 100644 tests/corpus/6005-class-instance-integrity-sloppy.cjs diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index d4939c788..473f21b0e 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -1 +1 @@ -- **Object freezing:** `Object.freeze` and `Object.isFrozen` support plain checked dictionaries and primitives. Freezing is shallow and preserves accessors. Aliased typed records, arrays, proxies, and other native objects cannot be frozen. +- **Object freezing:** `Object.freeze` and `Object.isFrozen` support plain checked dictionaries and primitives. Freezing is shallow and preserves accessors. Aliased typed records, arrays, proxies, and other native objects cannot be frozen. Class instances support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible` when the static type of the argument names the class. A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an accessor descriptor for a declared field throws `SC2020`. Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. A JavaScript class field that is first created after its instance became non-extensible is added instead of throwing. diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 8a53b5fa8..134c74b92 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -5,10 +5,12 @@ import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js"; import { type IrRecordShape, type IrType, + CLASS_ATTRIBUTES, DYN_CLASS_PROPERTIES, RUNTIME_STREAM_CLASSES, SYMBOL_T, classDynViewSupported, + classFieldAttributesName, isClassOwnEnumerableFieldName, isDirentShape, isDynTypedRefType, @@ -766,6 +768,7 @@ export function streamTypedRefMaterializeAdapter( host.declare(`declare ptr @scr_stream_dyn_view(ptr)`); B.line(`${out} = call ptr @scr_stream_dyn_view(ptr %p)`); } else B.line(`${out} = call ptr @scr_dyn_new_obj()`); + const withAttributes = meta.def.fields.some((field) => field.name === CLASS_ATTRIBUTES); if (meta.def.tracksOwnFields) { host.declare(`declare ptr @scr_dyn_copy_property_descriptors(ptr, ptr)`); host.declare(`declare void @scr_dyn_release_v(ptr)`); @@ -835,9 +838,27 @@ export function streamTypedRefMaterializeAdapter( const nullable = host.nullableFields.get(t.className, field.name); if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable); const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue); + // Attributes copied from the bag do not stop the view taking the value. + const setter = withAttributes ? "scr_dyn_obj_set_field" : "scr_dyn_obj_set"; + if (withAttributes) + host.declare(`declare void @scr_dyn_obj_set_field(ptr, ptr, ${host.sizeType}, ptr)`); B.line( - `call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`, + `call void @${setter}(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`, ); + // A field redefined through Object.defineProperty keeps its attributes. + const attributesField = classFieldAttributesName(field.name); + if (meta.def.fields.some((f) => f.name === attributesField)) { + host.declare(`declare void @scr_dyn_class_field_attributes(ptr, ptr, double)`); + const slot = B.tmp(), + bits = B.tmp(); + B.line( + `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${classFieldIndex(meta, attributesField).index}`, + ); + B.line(`${bits} = load double, ptr ${slot}`); + B.line( + `call void @scr_dyn_class_field_attributes(ptr ${out}, ptr ${host.internLiteral(field.name)}, double ${bits})`, + ); + } if (nullable) { host.declare(`declare void @scr_union_release(ptr)`); B.line(`call void @scr_union_release(ptr ${fieldValue})`); @@ -920,6 +941,17 @@ export function streamTypedRefMaterializeAdapter( B.line(`${copied} = call ptr @scr_dyn_copy_property_descriptors(ptr ${out}, ptr ${bag})`); B.line(`call void @scr_dyn_release_v(ptr ${copied})`); } + // A frozen, sealed or non-extensible instance's view says so. + if (withAttributes) { + host.declare(`declare void @scr_dyn_class_view_attributes(ptr, double)`); + const slot = B.tmp(), + bits = B.tmp(); + B.line( + `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${classFieldIndex(meta, CLASS_ATTRIBUTES).index}`, + ); + B.line(`${bits} = load double, ptr ${slot}`); + B.line(`call void @scr_dyn_class_view_attributes(ptr ${out}, double ${bits})`); + } B.terminate(`ret ptr ${out}`); } else if (t.kind === "record") { const shape = host.recordsById.get(t.shapeId); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 7fef9ca7c..484ef944d 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -907,6 +907,12 @@ export const LIB_FN_SYMS: Record = { "dyn.objectTag": "scr_dyn_object_tag", "dyn.freeze": "scr_dyn_freeze", "dyn.isFrozen": "scr_dyn_is_frozen", + "class.defineField": "scr_class_define_field", + "class.definedBits": "scr_class_defined_bits", + "class.definedValue": "scr_class_defined_value", + "class.descriptorHasValue": "scr_class_descriptor_has_value", + "class.throwReadOnly": "scr_class_throw_read_only", + "class.throwNotExtensible": "scr_class_throw_not_extensible", "dyn.nativeSetNew": "scr_dyn_native_set_new", "dyn.nativeSetIs": "scr_dyn_native_set_is", "dyn.nativeMapIs": "scr_dyn_native_map_is", diff --git a/packages/compiler/src/frontend/lowering/class-attributes.ts b/packages/compiler/src/frontend/lowering/class-attributes.ts new file mode 100644 index 000000000..e3f4250a0 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-attributes.ts @@ -0,0 +1,416 @@ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + CLASS_ATTRIBUTES, + CLASS_FROZEN, + CLASS_NOT_EXTENSIBLE, + CLASS_SEALED, + DYN, + F64, + FIELD_NOT_WRITABLE, + STRING, + VOID, + canConvertToDyn, + canDynCheckTo, + classFieldAttributesName, + isClassOwnEnumerableFieldName, + isDynTypedRefType, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import { numLit, strLit, varRef } from "../../ir/build.js"; +import { transformStmtList } from "../../ir/traverse.js"; +import { locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import type { ClassInfo } from "./lower-classes.js"; +import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; +import { runtimeRooted } from "./class-descriptors.js"; + +/* Object.freeze, Object.seal, Object.preventExtensions and + * Object.defineProperty on class instances. Only the hierarchies these calls + * name pay for them: such a hierarchy gets a CLASS_ATTRIBUTES integrity word + * per instance, an attribute word per redefined field, and a check before + * each write to a declared field (guardClassAttributeWrites). The instance's + * checked view carries the attributes, so reflection answers from it. */ + +type ClassObjectType = Extract; + +function attributeClass(lowerer: Lowerer, target: IrExpr): ClassInfo | null { + if (!isDynTypedRefType(target.type)) return null; + const info = lowerer.classes.get(target.type.className); + if (!info || runtimeRooted(info) || info.decl == null) return null; + return info; +} + +/** Adds a zero-initialized f64 to every class of the hierarchy, at the same + * offset, like the property bag. */ +function addHiddenField(info: ClassInfo, name: string): void { + let root = info; + while (root.base && !root.base.def.runtime) root = root.base; + if (root.fields.has(name)) return; + const index = root.def.fields.length; + const add = (current: ClassInfo): void => { + if (!current.fields.has(name)) { + current.fields.set(name, F64); + current.def.fields.splice(index, 0, { name, type: F64 }); + } + current.subclasses.forEach(add); + }; + add(root); +} + +function hiddenFieldGet(receiver: IrExpr, className: string, field: string, loc: SrcLoc): IrExpr { + return { kind: "fieldGet", obj: receiver, className, field, type: F64, loc }; +} + +/** Object.freeze, Object.seal and Object.preventExtensions of a class + * instance: sets the instance's integrity bits, then applies the operation to + * the properties added to the instance. Answers the instance. */ +export function lowerClassIntegrity( + lowerer: Lowerer, + call: ts.CallExpression, + member: "freeze" | "seal" | "preventExtensions", + target: IrExpr, +): IrExpr | null { + const info = attributeClass(lowerer, target); + if (!info) return null; + addHiddenField(info, CLASS_ATTRIBUTES); + const loc = locOf(call); + const type = target.type as ClassObjectType; + const bits = + member === "freeze" + ? CLASS_NOT_EXTENSIBLE | CLASS_SEALED | CLASS_FROZEN + : member === "seal" + ? CLASS_NOT_EXTENSIBLE | CLASS_SEALED + : CLASS_NOT_EXTENSIBLE; + const slot = lowerer.declareHiddenLocal("%integrityReceiver", type); + const receiver = (): IrExpr => varRef(slot.id, type, loc); + const bag: IrExpr = { + kind: "call", + callee: classPropertiesHelper(lowerer, loc).name, + args: [lowerer.coerceToExpected(receiver(), DYN)], + type: DYN, + loc, + }; + const operation = { + freeze: "dyn.freeze", + seal: "dyn.seal", + preventExtensions: "dyn.preventExtensions", + } as const; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: slot.id, init: target, loc }, + { + kind: "fieldSet", + obj: receiver(), + className: type.className, + field: CLASS_ATTRIBUTES, + value: { + kind: "bin", + op: "|", + left: hiddenFieldGet(receiver(), type.className, CLASS_ATTRIBUTES, loc), + right: numLit(bits, loc), + type: F64, + loc, + }, + attributesChecked: true, + loc, + }, + { + kind: "exprStmt", + expr: { kind: "libCall", fn: operation[member], args: [bag], type: DYN, loc }, + loc, + }, + ], + result: receiver(), + type, + loc, + }; +} + +/** Object.defineProperty with a string-literal key naming a declared field + * of a class instance: a data descriptor changes the field's value and + * attributes, validated as Node validates ordinary objects. Answers the + * instance, or null when the call is not of that form. */ +export function lowerClassFieldDefine( + lowerer: Lowerer, + call: ts.CallExpression, + target: IrExpr, +): IrExpr | null { + const info = attributeClass(lowerer, target); + const key = call.arguments[1]!; + if (!info || !ts.isStringLiteral(key)) return null; + const field = key.text; + const fieldType = info.fields.get(field); + if ( + fieldType === undefined || + !isClassOwnEnumerableFieldName(field) || + [...(info.symbolFields?.values() ?? [])].includes(field) + ) + return null; + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + if ( + !canConvertToDyn(fieldType, getRecord, getUnion) || + !canDynCheckTo(fieldType, getRecord, getUnion) + ) + return null; + const attributesField = classFieldAttributesName(field); + addHiddenField(info, CLASS_ATTRIBUTES); + addHiddenField(info, attributesField); + const loc = locOf(call); + const type = target.type as ClassObjectType; + const receiverSlot = lowerer.declareHiddenLocal("%defineReceiver", type); + const descriptorSlot = lowerer.declareHiddenLocal("%defineDescriptor", DYN); + const holderSlot = lowerer.declareHiddenLocal("%defineHolder", DYN); + const receiver = (): IrExpr => varRef(receiverSlot.id, type, loc); + const descriptor = (): IrExpr => varRef(descriptorSlot.id, DYN, loc); + const holder = (): IrExpr => varRef(holderSlot.id, DYN, loc); + const name = (): IrExpr => strLit(field, loc); + const current: IrExpr = { + kind: "fieldGet", + obj: receiver(), + className: type.className, + field, + type: fieldType, + loc, + }; + const defined: IrExpr = { + kind: "libCall", + fn: "class.definedValue", + args: [holder(), name()], + type: DYN, + loc, + }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiverSlot.id, init: target, loc }, + { + kind: "varDecl", + localId: descriptorSlot.id, + init: lowerer.lowerExprExpecting(call.arguments[2]!, DYN), + loc, + }, + { + kind: "varDecl", + localId: holderSlot.id, + init: { + kind: "libCall", + fn: "class.defineField", + args: [ + lowerer.coerceToExpected(current, DYN), + hiddenFieldGet(receiver(), type.className, attributesField, loc), + hiddenFieldGet(receiver(), type.className, CLASS_ATTRIBUTES, loc), + name(), + descriptor(), + ], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: { + kind: "libCall", + fn: "class.descriptorHasValue", + args: [descriptor()], + type: BOOL, + loc, + }, + then: [ + { + kind: "fieldSet", + obj: receiver(), + className: type.className, + field, + value: lowerer.coerceToExpected(defined, fieldType), + attributesChecked: true, + loc, + }, + ], + else_: null, + loc, + }, + { + kind: "fieldSet", + obj: receiver(), + className: type.className, + field: attributesField, + value: { + kind: "libCall", + fn: "class.definedBits", + args: [holder(), name()], + type: F64, + loc, + }, + attributesChecked: true, + loc, + }, + ], + result: receiver(), + type, + loc, + }; +} + +function classDisplayName(info: ClassInfo): string { + return info.def.jsName ?? info.decl?.name?.text ?? info.def.name.replace(/^%/, ""); +} + +/** The instance's own class name for an error message: a helper testing the + * most derived classes of the hierarchy first. */ +function instanceClassName( + lowerer: Lowerer, + info: ClassInfo, + receiver: IrExpr, + helpers: Map, + loc: SrcLoc, +): IrExpr { + if (info.subclasses.length === 0) return strLit(classDisplayName(info), loc); + let helper = helpers.get(info.def.name); + if (!helper) { + const type: IrType = { kind: "object", className: info.def.name }; + const value = varRef("value", type, loc); + const descendants = (current: ClassInfo): ClassInfo[] => + current.subclasses.flatMap((child) => [...descendants(child), child]); + const body: IrStmt[] = descendants(info).map((child): IrStmt => ({ + kind: "if", + cond: { kind: "instanceOf", value, className: child.def.name, type: BOOL, loc }, + then: [{ kind: "return", value: strLit(classDisplayName(child), loc), loc }], + else_: null, + loc, + })); + body.push({ kind: "return", value: strLit(classDisplayName(info), loc), loc }); + helper = { + name: `%class.instanceName.${helpers.size}`, + params: [{ localId: "value", name: "value", type }], + returnType: STRING, + locals: [{ id: "value", name: "value", type, mutable: false }], + body, + loc, + }; + helpers.set(info.def.name, helper); + } + return { kind: "call", callee: helper.name, args: [receiver], type: STRING, loc }; +} + +/** Checks the integrity word, and the field's attribute word when it has one, + * before every write to a declared field of a hierarchy that has them. A + * read-only field throws Node's TypeError in strict mode code; a sloppy-mode + * write leaves it unchanged. Other classes keep their plain stores. */ +export function guardClassAttributeWrites(lowerer: Lowerer, functions: IrFunction[]): void { + const guarded = new Set( + [...lowerer.classes.values()] + .filter((info) => info.def.fields.some((field) => field.name === CLASS_ATTRIBUTES)) + .map((info) => info.def.name), + ); + if (guarded.size === 0) return; + const helpers = new Map(); + for (const fn of functions) { + const local = (type: IrType, loc: SrcLoc): Extract => { + const id = `%attributeWrite.${fn.locals.length}`; + fn.locals.push({ id, name: id, type, mutable: false }); + return { kind: "varRef", localId: id, type, loc }; + }; + fn.body = transformStmtList(fn.body, { + stmt: (statement) => { + if ( + statement.kind !== "fieldSet" || + statement.attributesChecked || + !guarded.has(statement.className) || + !isClassOwnEnumerableFieldName(statement.field) + ) + return statement; + const info = lowerer.classes.get(statement.className)!; + const loc = statement.loc; + const receiver = local(statement.obj.type, loc); + const value = local(statement.value.type, loc); + const masked = (field: string, bit: number): IrExpr => ({ + kind: "bin", + op: "&", + left: hiddenFieldGet(receiver, statement.className, field, loc), + right: numLit(bit, loc), + type: F64, + loc, + }); + const attributesField = classFieldAttributesName(statement.field); + let readOnly = masked( + CLASS_ATTRIBUTES, + statement.define ? CLASS_NOT_EXTENSIBLE : CLASS_FROZEN, + ); + if (!statement.define && info.fields.has(attributesField)) { + readOnly = { + kind: "bin", + op: "|", + left: readOnly, + right: masked(attributesField, FIELD_NOT_WRITABLE), + type: F64, + loc, + }; + } + const reject: IrStmt[] = statement.define + ? [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "class.throwNotExtensible", + args: [strLit(statement.field, loc)], + type: VOID, + loc, + }, + loc, + }, + ] + : statement.sloppy + ? [] + : [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "class.throwReadOnly", + args: [ + strLit(statement.field, loc), + instanceClassName(lowerer, info, receiver, helpers, loc), + ], + type: VOID, + loc, + }, + loc, + }, + ]; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: receiver.localId, init: statement.obj, loc }, + { kind: "varDecl", localId: value.localId, init: statement.value, loc }, + { + kind: "if", + cond: { + kind: "bin", + op: "!==", + left: readOnly, + right: numLit(0, loc), + type: BOOL, + loc, + }, + then: reject, + else_: [{ ...statement, obj: receiver, value, attributesChecked: true }], + loc, + }, + ], + loc, + }; + }, + expr: (expression) => expression, + }); + } + for (const helper of helpers.values()) functions.push(helper); +} diff --git a/packages/compiler/src/frontend/lowering/class-descriptors.ts b/packages/compiler/src/frontend/lowering/class-descriptors.ts index 36e280707..bbfbc2c51 100644 --- a/packages/compiler/src/frontend/lowering/class-descriptors.ts +++ b/packages/compiler/src/frontend/lowering/class-descriptors.ts @@ -23,7 +23,7 @@ function bagOnlyProperty(owner: ClassInfo, name: string): boolean { return !owner.fields.has(name) && owner.subclasses.every((child) => bagOnlyProperty(child, name)); } -function runtimeRooted(info: ClassInfo): boolean { +export function runtimeRooted(info: ClassInfo): boolean { for (let current: ClassInfo | null = info; current; current = current.base) { if ( current.def.runtime || diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index b3cd7e39b..c91ce3fcd 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -215,6 +215,7 @@ import { } from "./optional-arguments.js"; import { fenceSymbolFieldCopy } from "./symbol-fields.js"; import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js"; +import { lowerClassFieldDefine, lowerClassIntegrity } from "./class-attributes.js"; import { classStaticDataFor } from "./class-static-data.js"; import { lowerUrlFactory } from "./lower-url.js"; import { nullishValueUnitOf } from "./binding-analysis.js"; @@ -7415,6 +7416,8 @@ function lowerObjectStaticCall( if (target && declaredTarget && isDynTypedRefType(declaredTarget)) target = lowerer.coerceToExpected(target, declaredTarget); if (target && member === "defineProperty") { + const field = lowerClassFieldDefine(lowerer, call, target); + if (field) return field; const native = lowerClassDataDescriptor(lowerer, call, member, target); if (native) return native; } @@ -7557,6 +7560,19 @@ function lowerObjectStaticCall( loc, }; } + if (member === "preventExtensions" || member === "seal") { + const integrity = lowerClassIntegrity(lowerer, call, member, value); + if (integrity) return integrity; + } else if (isDynTypedRefType(value.type)) { + // The instance's checked view carries its integrity state. + return { + kind: "libCall", + fn: member === "isSealed" ? "dyn.isSealed" : "dyn.isExtensible", + args: [lowerer.coerceToExpected(value, DYN)], + type: BOOL, + loc: locOf(call), + }; + } if (value.type.kind === "dyn") { const operation = { preventExtensions: "dyn.preventExtensions", @@ -7627,6 +7643,8 @@ function lowerObjectStaticCall( ) ) return value; + const integrity = lowerClassIntegrity(lowerer, call, "freeze", value); + if (integrity) return integrity; lowerer.noLowering( "Object.freeze of a possibly-aliased value", call, diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index d44748084..b3d38c1ad 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -7758,6 +7758,7 @@ export function fieldInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrL className: info.def.name, field: f.name, value: dynUndefinedExpr(loc), + define: true, loc, }); } @@ -7773,6 +7774,7 @@ export function fieldInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrL className: info.def.name, field: f.name, value, + define: true, loc: locOf(f.initializer), }); } catch (e) { diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 42744212d..c1ad8c429 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -64,6 +64,7 @@ import { maybePromiseInstanceOf, maybePromiseUnionOf, } from "./maybe-promise.js"; +import { isStrictCode } from "./strict-mode.js"; import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js"; @@ -16149,6 +16150,7 @@ export function fieldSetStmt( className: target.className, field: target.field, value, + ...(isStrictCode(blame) ? {} : { sloppy: true as const }), loc, } : { diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 37233bcdc..5aeeef9b3 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -20,6 +20,7 @@ import { type SrcLoc, canConvertToDyn, canDynCheckTo, + CLASS_FIELD_ATTRIBUTES_PREFIX, classDynViewSupported, DYN_CLASS_PROPERTIES, isClassOwnEnumerableFieldName, @@ -499,12 +500,16 @@ function classInspectsThroughView(lowerer: Lowerer, info: ClassInfo | undefined) ); } -/** Whether a class holds properties added outside its declared layout, which - * only its checked-dynamic view can list. Decided after lowering, when the +/** Whether a class holds properties added outside its declared layout, or + * fields Object.defineProperty can make non-enumerable, which only its + * checked-dynamic view lists as Node does. Decided after lowering, when the * class has its final fields. */ function classInspectsAddedProperties(lowerer: Lowerer, info: ClassInfo): boolean { return ( - info.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) && + info.def.fields.some( + (field) => + field.name === DYN_CLASS_PROPERTIES || field.name.startsWith(CLASS_FIELD_ATTRIBUTES_PREFIX), + ) && classDynViewSupported( info.def.fields, (id) => lowerer.shapes.get(id), diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 700f68a2e..252b673da 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -85,10 +85,10 @@ import { isCjsJsFile, isEsModuleStamp, isJsSourceFile, - isNodeEsmFile, locOf, requireSpecOf, } from "../program.js"; +import { isStrictCode } from "./strict-mode.js"; import { COMPOUND_ASSIGN_OPS, type CompoundOp, @@ -5879,35 +5879,6 @@ export function lowerTry(lowerer: Lowerer, stmt: ts.TryStatement): IrStmt { }; } -function hasUseStrictDirective(statements: readonly ts.Statement[]): boolean { - for (const statement of statements) { - if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) - return false; - if (statement.expression.text === "use strict") return true; - } - return false; -} - -function isStrictDelete(node: ts.DeleteExpression): boolean { - for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) { - if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return true; - if ( - ts.isFunctionLike(parent) && - parent.body && - ts.isBlock(parent.body) && - hasUseStrictDirective(parent.body.statements) - ) - return true; - if (ts.isSourceFile(parent)) { - return ( - hasUseStrictDirective(parent.statements) || - (!isCjsJsFile(parent) && (isNodeEsmFile(parent) || ts.isExternalModule(parent))) - ); - } - } - return false; -} - /** Statement-position `delete`: process.env keys → process.envUnset * (unsetenv), pure `Record` keys → recordKeyDelete (the * overflow Map delete), declared OPTIONAL fields → the field's ABSENT @@ -6001,7 +5972,7 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt obj = lowerer.coerceToExpected(obj, DYN); } if (obj.type.kind === "dyn") { - const strict = { kind: "boolLit", value: isStrictDelete(expr), type: BOOL, loc } as const; + const strict = { kind: "boolLit", value: isStrictCode(expr), type: BOOL, loc } as const; if (ts.isElementAccessExpression(target)) { const key = lowerer.lowerExprExpecting(target.argumentExpression, DYN); return { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2fb266be0..1b1b46174 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -112,6 +112,7 @@ import { InternalCompilerError } from "../../errors.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js"; import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; +import { guardClassAttributeWrites } from "./class-attributes.js"; import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; @@ -6772,6 +6773,7 @@ export class Lowerer { if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); trackClassFieldCreation(this, functions); + guardClassAttributeWrites(this, functions); // Globals typed by a class that never REGISTERED (a JS class whose // collection fenced — Symbol-keyed fields, an unsupported base): the diff --git a/packages/compiler/src/frontend/lowering/strict-mode.ts b/packages/compiler/src/frontend/lowering/strict-mode.ts new file mode 100644 index 000000000..a97e32f9e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/strict-mode.ts @@ -0,0 +1,34 @@ +import * as ts from "../ts7/adapter.js"; +import { isCjsJsFile, isNodeEsmFile } from "../program.js"; + +function hasUseStrictDirective(statements: readonly ts.Statement[]): boolean { + for (const statement of statements) { + if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) + return false; + if (statement.expression.text === "use strict") return true; + } + return false; +} + +/** Whether `node` runs in strict mode code: inside a class body, a function + * with a "use strict" directive, a module, or a script that starts with + * "use strict". CommonJS JavaScript is sloppy otherwise. */ +export function isStrictCode(node: ts.Node): boolean { + for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) { + if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return true; + if ( + ts.isFunctionLike(parent) && + parent.body && + ts.isBlock(parent.body) && + hasUseStrictDirective(parent.body.statements) + ) + return true; + if (ts.isSourceFile(parent)) { + return ( + hasUseStrictDirective(parent.statements) || + (!isCjsJsFile(parent) && (isNodeEsmFile(parent) || ts.isExternalModule(parent))) + ); + } + } + return false; +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 41d18a6a2..617c0efee 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -6,6 +6,7 @@ import type { IrRecordShape, IrType, IrUnionDef, IrUnionDiscriminant } from "../ import { BYTES_ELEMENT_NAME, arrayOf, + isDynTypedRefType, BOOL, bytesOf, canConvertToDyn, @@ -1511,6 +1512,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { contextResolutions++; return DYN; } + // Readonly over a program class (Object.freeze's result type) is the + // same instance. + const readonlyClass = readonlyClassType(type, ctx); + if (readonlyClass !== null) return readonlyClass; if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) { const bound = resolveTypeParam(type); if (bound) { @@ -5111,6 +5116,19 @@ function mentionsMappedParams( return false; } +function readonlyClassType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const alias = type.getAliasSymbol(); + if (alias?.name !== "Readonly") return null; + const args = type.getAliasTypeArguments(); + if (args.length !== 1 || !args[0]) return null; + const isLibAlias = ctx.checker + .declarationsOf(alias) + .some((d) => ts.isTypeAliasDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())); + if (!isLibAlias) return null; + const mapped = mapType(args[0], ctx); + return mapped !== null && isDynTypedRefType(mapped) ? mapped : null; +} + function mapGenericUtilityAlias(widened: ts.Type, ctx: TypeMapperCtx): IrType | null { const { resolveTypeParam, shapes, unions } = ctx; if (!resolveTypeParam) return null; diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index fc4c383ff..c4fa209c8 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -577,6 +577,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.mapSeedEntry", "dyn.freeze", "dyn.isFrozen", + "class.defineField", + "class.throwReadOnly", + "class.throwNotExtensible", "dyn.toString", "dyn.valueOf", "string.fromCodePoint", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 15b8d1627..cb41dec6a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -202,6 +202,23 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.in": { argTypes: [DYN, STRING], result: BOOL }, "dyn.freeze": { argTypes: [DYN], result: DYN }, "dyn.isFrozen": { argTypes: [DYN], result: BOOL }, + /** Object.defineProperty on a declared class field (scr_json.c): the + * field's current value (borrowed), its FIELD_* attribute word, the + * instance's CLASS_* integrity word, the field name and the descriptor. + * Validates as Node does (TypeError "Cannot redefine property: f") and + * answers a one-property holder of the defined value and attributes (+1), + * read back with class.definedBits and class.definedValue. */ + "class.defineField": { argTypes: [DYN, F64, F64, STRING, DYN], result: DYN }, + "class.definedBits": { argTypes: [DYN, STRING], result: F64 }, + "class.definedValue": { argTypes: [DYN, STRING], result: DYN }, + /** Whether a property descriptor has a `value` member. */ + "class.descriptorHasValue": { argTypes: [DYN], result: BOOL }, + /** Throws Node's TypeError for a write to a read-only class field: the + * field name and the instance's class name. */ + "class.throwReadOnly": { argTypes: [STRING, STRING], result: VOID }, + /** Throws Node's TypeError for a class field initializer on a + * non-extensible instance: the field name. */ + "class.throwNotExtensible": { argTypes: [STRING], result: VOID }, "dyn.nativeSetNew": { argTypes: [DYN], result: DYN }, "dyn.nativeSetIs": { argTypes: [DYN], result: BOOL }, "dyn.nativeMapIs": { argTypes: [DYN], result: BOOL }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 87a4de606..aee26d5ba 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1896,8 +1896,23 @@ export type IrStmt = | { kind: "return"; value: IrExpr | null; awaited?: true; loc: SrcLoc } /** Field write `obj.f = v` — statement-only, like `assign`/`arraySet`. * Evaluation order: obj, then value. The old value is released; ownership - * of a refcounted new value MOVES into the object. */ - | { kind: "fieldSet"; obj: IrExpr; className: string; field: string; value: IrExpr; loc: SrcLoc } + * of a refcounted new value MOVES into the object. `sloppy` marks a write + * in sloppy-mode code, which skips a read-only field instead of throwing. + * `define` marks a class field initializer, which defines the property + * (a non-extensible instance rejects it). `attributesChecked` marks a + * write that already honors the instance's property attributes + * (CLASS_ATTRIBUTES). */ + | { + kind: "fieldSet"; + obj: IrExpr; + className: string; + field: string; + value: IrExpr; + sloppy?: true; + define?: true; + attributesChecked?: true; + loc: SrcLoc; + } /** Record field write `r.f = v` — mirrors `fieldSet` exactly (evaluation * order obj then value; old value released; refcounted new value moved * in), with a shape id in place of a class name. */ @@ -4048,6 +4063,27 @@ export function isClassOwnEnumerableFieldName(name: string): boolean { /** Compiler-owned storage for properties added through untyped references. */ export const DYN_CLASS_PROPERTIES = "%dynProperties"; +/** Compiler-owned integrity state of a class instance whose hierarchy is + * passed to Object.freeze, Object.seal, Object.preventExtensions or + * Object.defineProperty: an f64 holding CLASS_NOT_EXTENSIBLE, CLASS_SEALED + * and CLASS_FROZEN bits. Other hierarchies have no such field. */ +export const CLASS_ATTRIBUTES = "%attributes"; +export const CLASS_NOT_EXTENSIBLE = 1; +export const CLASS_SEALED = 2; +export const CLASS_FROZEN = 4; + +/** Compiler-owned attributes of one declared field that Object.defineProperty + * changes: an f64 holding FIELD_NOT_WRITABLE, FIELD_NOT_ENUMERABLE and + * FIELD_NOT_CONFIGURABLE bits (zero is an ordinary data property). */ +export const CLASS_FIELD_ATTRIBUTES_PREFIX = "%attr:"; +export const FIELD_NOT_WRITABLE = 1; +export const FIELD_NOT_ENUMERABLE = 2; +export const FIELD_NOT_CONFIGURABLE = 4; + +export function classFieldAttributesName(field: string): string { + return CLASS_FIELD_ATTRIBUTES_PREFIX + field; +} + /** A class capsule can always preserve its exact native identity. Its * optional property view additionally needs converters in both directions; * fields such as Maps may remain opaque without preventing the round trip. */ diff --git a/packages/compiler/test/class-attributes.test.ts b/packages/compiler/test/class-attributes.test.ts new file mode 100644 index 000000000..1a6fea654 --- /dev/null +++ b/packages/compiler/test/class-attributes.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +async function lowerModule(source: string, file = "main.ts"): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-class-attributes-")); + try { + const entry = join(dir, file); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function fieldsOf(mod: IrModule, className: string): string[] { + const cls = (mod.classes ?? []).find( + (c) => c.name === className || c.name.endsWith(`.${className}`), + ); + if (!cls) throw new Error(`no class ${className}`); + return cls.fields.map((f) => f.name); +} + +function functionsCalling(mod: IrModule, fn: string): string[] { + return mod.functions + .filter((f) => JSON.stringify(f.body).includes(`"fn":"${fn}"`)) + .map((f) => f.name); +} + +test("only hierarchies passed to an integrity call get attribute state and write checks", async () => { + const mod = await lowerModule(` +export {}; +class Point { x = 1; } +class Other { x = 1; } +function movePoint(p: Point): void { p.x = 2; } +function moveOther(o: Other): void { o.x = 2; } +const p = new Point(); +Object.freeze(p); +movePoint(p); +moveOther(new Other()); +`); + expect(fieldsOf(mod, "Point")).toContain("%attributes"); + expect(fieldsOf(mod, "Other")).not.toContain("%attributes"); + const checked = functionsCalling(mod, "class.throwReadOnly"); + expect(checked).toContain("movePoint"); + expect(checked).not.toContain("moveOther"); +}); + +test("a redefined field gets its own attribute word", async () => { + const mod = await lowerModule(` +export {}; +class Config { mode = "dev"; level = 1; } +const c = new Config(); +Object.defineProperty(c, "mode", { writable: false }); +`); + const fields = fieldsOf(mod, "Config"); + expect(fields).toContain("%attributes"); + expect(fields).toContain("%attr:mode"); + expect(fields).not.toContain("%attr:level"); +}); + +test("sloppy-mode writes skip a read-only field without throwing", async () => { + const mod = await lowerModule( + ` +class Box { constructor() { this.x = 1; } } +const b = new Box(); +Object.freeze(b); +b.x = 2; +`, + "main.cjs", + ); + expect(fieldsOf(mod, "Box")).toContain("%attributes"); + // Class bodies are strict mode code; the top-level write is not. + const throwing = functionsCalling(mod, "class.throwReadOnly"); + expect(throwing.length).toBeGreaterThan(0); + expect(throwing.every((name) => name.includes("constructor"))).toBe(true); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json new file mode 100644 index 000000000..4d21236c0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6004-class-instance-integrity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json new file mode 100644 index 000000000..1101d2180 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6005-class-instance-integrity-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 1ab857e57..92a5b648a 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -882,7 +882,17 @@ double scr_dyn_arr_len(const ScrDyn *d); ScrDyn *scr_dyn_arr_at(const ScrDyn *d, double i); ScrDyn *scr_dyn_arr_named_get(const ScrDyn *d, const ScrStr *key); void scr_dyn_obj_set(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value); +void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value); ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor); +void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits); +void scr_dyn_class_view_attributes(ScrDyn *view, double bits); +ScrDyn *scr_class_define_field(ScrDyn *current, double field_bits, double object_bits, ScrStr *name, + ScrDyn *descriptor); +double scr_class_defined_bits(ScrDyn *holder, ScrStr *name); +ScrDyn *scr_class_defined_value(ScrDyn *holder, ScrStr *name); +bool scr_class_descriptor_has_value(ScrDyn *descriptor); +void scr_class_throw_read_only(ScrStr *field, ScrStr *owner); +void scr_class_throw_not_extensible(ScrStr *field); ScrDyn *scr_dyn_get_own_property_descriptor(ScrDyn *target, ScrDyn *key); ScrDyn *scr_dyn_array_proto_call(ScrDyn *target, ScrStr *method, ScrDyn *args); ScrDyn *scr_dyn_array_prototype(void); /* +1, installs native method adapters */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 7accb818b..5c8d8858c 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1585,8 +1585,12 @@ static void scr_dyn_obj_put(ScrDyn *obj, const char *key, size_t key_len, ScrDyn } if (obj->non_extensible) { scr_dyn_release(value); - static const char message[] = "Cannot add property, object is not extensible"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, "Cannot add property "); + scr_jb_write(&buffer, key, key_len); + scr_jb_puts(&buffer, ", object is not extensible"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); return; } if (obj->v.obj.len == obj->v.obj.cap) { @@ -1922,9 +1926,40 @@ ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) { return scr_dyn_retain(object); } +/* A class instance's integrity state lives in its native layout, which only + * statically typed calls can reach (the compiler adds the state to the + * hierarchies they name). Through an untyped value the operation throws. */ +static bool scr_dyn_class_integrity_fence(const ScrDyn *object, const char *operation) { + if (object->kind != SCR_DYN_TYPED_REF) return false; + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, operation); + scr_jb_puts(&buffer, " of a class instance held in an untyped value is not supported yet"); + ScrStr *message = scr_jb_finish(&buffer); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + scr_str_release(message); + return true; +} + +/* Integrity queries on a class instance answer from its checked view, which + * carries the instance's attributes (scr_dyn_class_view_attributes). */ +static bool scr_dyn_typed_ref_view_answer(ScrDyn *object, bool (*query)(const ScrDyn *), + bool *answer) { + if (object->kind != SCR_DYN_TYPED_REF) return false; + ScrDyn *view = scr_dyn_typed_ref_materialize(object); + *answer = scr_exc_pending() ? false : query(view); + scr_dyn_release(view); + return true; +} + +static bool scr_dyn_is_extensible_view(const ScrDyn *view) { + return (view->kind == SCR_DYN_OBJ || view->kind == SCR_DYN_ARR) && !view->non_extensible; +} + ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return NULL; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.preventExtensions"); return NULL; } + if (scr_dyn_class_integrity_fence(object, "Object.preventExtensions")) return NULL; if (object->kind == SCR_DYN_OBJ || object->kind == SCR_DYN_ARR) { object->non_extensible = true; if (object->kind == SCR_DYN_ARR && object->v.arr.properties) @@ -1936,12 +1971,15 @@ ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { bool scr_dyn_is_extensible(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return false; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isExtensible"); return false; } - return (object->kind == SCR_DYN_OBJ || object->kind == SCR_DYN_ARR) && !object->non_extensible; + bool answer; + if (scr_dyn_typed_ref_view_answer(object, scr_dyn_is_extensible_view, &answer)) return answer; + return scr_dyn_is_extensible_view(object); } ScrDyn *scr_dyn_seal(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return NULL; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.seal"); return NULL; } + if (scr_dyn_class_integrity_fence(object, "Object.seal")) return NULL; if (object->symbol_properties) scr_dyn_release(scr_dyn_seal(object->symbol_properties)); if (object->kind == SCR_DYN_OBJ) { object->non_extensible = true; @@ -1958,6 +1996,8 @@ ScrDyn *scr_dyn_seal(ScrDyn *object) { bool scr_dyn_is_sealed(const ScrDyn *object) { if (scr_dyn_class_reflection_fence(object)) return false; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isSealed"); return false; } + bool answer; + if (scr_dyn_typed_ref_view_answer((ScrDyn *)object, scr_dyn_is_sealed, &answer)) return answer; if (object->symbol_properties && !scr_dyn_is_sealed(object->symbol_properties)) return false; if (object->kind == SCR_DYN_ARR) return object->non_extensible && (object->v.arr.len == 0 || object->v.arr.sealed) && @@ -4243,6 +4283,25 @@ void scr_dyn_obj_set(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value scr_dyn_obj_put(obj, key, key_len, value); } +/* A class instance's view takes its native field values whatever the + * attributes copied into it (a frozen instance's view is built the same way + * as any other). Consumes value. */ +void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value) { + ScrDynEntry *existing = obj->v.obj.index ? scr_dyn_find_entry(obj, key, key_len) + : scr_dyn_find_linear_entry(obj, key, key_len); + if (existing && !existing->accessor) { + scr_dyn_region_store(obj, value); + ScrDyn *old = existing->value; + existing->value = value; + scr_dyn_release(old); + return; + } + bool non_extensible = obj->non_extensible; + obj->non_extensible = false; + scr_dyn_obj_put(obj, key, key_len, value); + obj->non_extensible = non_extensible; +} + static ScrStr *scr_dyn_property_key(const ScrDyn *key) { return scr_dyn_string_coerce_js(key); } @@ -6036,9 +6095,12 @@ ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor) (!accessor && ((!prior->writable && writable) || (!prior->writable && value && !scr_dyn_property_same_value(prior->value, value)))))) { scr_dyn_descriptor_fields_drop(fields); + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, "Cannot redefine property: "); + scr_jb_write(&buffer, name->data, name->len); scr_str_release(name); - static const char msg[] = "Cannot redefine property"; - scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); return NULL; } if (prior) { @@ -13864,6 +13926,7 @@ ScrStr *scr_dyn_to_string_method(const ScrDyn *d, const ScrStr *enc, const ScrSt ScrDyn *scr_dyn_freeze(ScrDyn *value) { if (scr_dyn_class_reflection_fence(value)) return NULL; + if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL; if (value->symbol_properties) scr_dyn_release(scr_dyn_freeze(value->symbol_properties)); if (value->kind == SCR_DYN_ARR) { value->non_extensible = true; @@ -13891,6 +13954,8 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value) { bool scr_dyn_is_frozen(const ScrDyn *value) { if (scr_dyn_class_reflection_fence(value)) return false; + bool answer; + if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer; if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false; if (value->kind == SCR_DYN_ARR) return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) && @@ -13944,3 +14009,127 @@ ScrDyn *scr_caught_to_dyn(const ScrCaught *c) { return scr_dyn_new_obj(); } } + +/* ── class instance attributes ─────────────────────────────────────────── + * A hierarchy passed to Object.freeze, Object.seal, Object.preventExtensions + * or Object.defineProperty keeps an integrity state per instance and an + * attribute word per redefined field (CLASS_* and FIELD_* bits in the + * compiler's ir.ts). The checked view of an instance carries them, so + * reflection over the view answers as Node does. */ +enum { + SCR_CLASS_NOT_EXTENSIBLE = 1, + SCR_CLASS_SEALED = 2, + SCR_CLASS_FROZEN = 4, + SCR_FIELD_NOT_WRITABLE = 1, + SCR_FIELD_NOT_ENUMERABLE = 2, + SCR_FIELD_NOT_CONFIGURABLE = 4, +}; + +void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits) { + unsigned b = (unsigned)bits; + if (b == 0 || view->kind != SCR_DYN_OBJ) return; + ScrDynEntry *entry = scr_dyn_entry(view, name); + if (!entry) return; + if (b & SCR_FIELD_NOT_WRITABLE) entry->writable = false; + if (b & SCR_FIELD_NOT_ENUMERABLE) entry->enumerable = false; + if (b & SCR_FIELD_NOT_CONFIGURABLE) entry->configurable = false; +} + +void scr_dyn_class_view_attributes(ScrDyn *view, double bits) { + unsigned b = (unsigned)bits; + if (b == 0 || view->kind != SCR_DYN_OBJ) return; + view->non_extensible = true; + for (size_t i = 0; i < view->v.obj.len; i++) { + ScrDynEntry *entry = &view->v.obj.entries[i]; + if (b & (SCR_CLASS_SEALED | SCR_CLASS_FROZEN)) entry->configurable = false; + if ((b & SCR_CLASS_FROZEN) && !entry->accessor) entry->writable = false; + } +} + +/* Object.defineProperty on a declared class field. The descriptor applies to + * a one-property object holding the field's current value and attributes, + * so validation and errors match ordinary objects. Answers that object (+1), + * or NULL with a pending exception. */ +ScrDyn *scr_class_define_field(ScrDyn *current, double field_bits, double object_bits, ScrStr *name, + ScrDyn *descriptor) { + unsigned f = (unsigned)field_bits, o = (unsigned)object_bits; + ScrDyn *holder = scr_dyn_new_obj(); + scr_dyn_obj_set(holder, name->data, name->len, scr_dyn_retain(current)); + ScrDynEntry *entry = scr_dyn_entry(holder, name); + entry->writable = !(f & SCR_FIELD_NOT_WRITABLE) && !(o & SCR_CLASS_FROZEN); + entry->enumerable = !(f & SCR_FIELD_NOT_ENUMERABLE); + entry->configurable = + !(f & SCR_FIELD_NOT_CONFIGURABLE) && !(o & (SCR_CLASS_SEALED | SCR_CLASS_FROZEN)); + ScrDyn *key = scr_dyn_new_str(name); + ScrDyn *defined = scr_dyn_define_property(holder, key, descriptor); + scr_dyn_release(key); + if (!defined) { + scr_dyn_release(holder); + return NULL; + } + scr_dyn_release(defined); + entry = scr_dyn_entry(holder, name); + if (entry->accessor) { + scr_dyn_release(holder); + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, "Object.defineProperty with an accessor descriptor for the declared field '"); + scr_jb_write(&buffer, name->data, name->len); + scr_jb_puts(&buffer, "' is not supported yet"); + ScrStr *message = scr_jb_finish(&buffer); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + scr_str_release(message); + return NULL; + } + return holder; +} + +/* The field attribute word of a scr_class_define_field result. */ +double scr_class_defined_bits(ScrDyn *holder, ScrStr *name) { + const ScrDynEntry *entry = scr_dyn_entry(holder, name); + unsigned bits = 0; + if (!entry->writable) bits |= SCR_FIELD_NOT_WRITABLE; + if (!entry->enumerable) bits |= SCR_FIELD_NOT_ENUMERABLE; + if (!entry->configurable) bits |= SCR_FIELD_NOT_CONFIGURABLE; + return (double)bits; +} + +/* The value a scr_class_define_field result holds (+1). */ +ScrDyn *scr_class_defined_value(ScrDyn *holder, ScrStr *name) { + return scr_dyn_retain(scr_dyn_entry(holder, name)->value); +} + +/* Whether a property descriptor names a value (Node's ToPropertyDescriptor + * reads `value` with [[HasProperty]]). */ +bool scr_class_descriptor_has_value(ScrDyn *descriptor) { + if (descriptor->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(descriptor); + bool has = !scr_exc_pending() && scr_class_descriptor_has_value(view); + scr_dyn_release(view); + return has; + } + return descriptor->kind == SCR_DYN_OBJ && scr_dyn_obj_get(descriptor, "value", 5) != NULL; +} + +/* Node's TypeError for a write to a read-only field of a class instance. */ +void scr_class_throw_read_only(ScrStr *field, ScrStr *owner) { + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, "Cannot assign to read only property '"); + scr_jb_write(&buffer, field->data, field->len); + scr_jb_puts(&buffer, "' of object '#<"); + scr_jb_write(&buffer, owner->data, owner->len); + scr_jb_puts(&buffer, ">'"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); +} + +/* Node's TypeError for a class field initializer on a non-extensible + * instance (a base constructor froze or sealed `this`). */ +void scr_class_throw_not_extensible(ScrStr *field) { + ScrJsonBuf buffer; + scr_jb_init(&buffer); + scr_jb_puts(&buffer, "Cannot define property "); + scr_jb_write(&buffer, field->data, field->len); + scr_jb_puts(&buffer, ", object is not extensible"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer)); +} diff --git a/tests/corpus/6004-class-instance-integrity.ts b/tests/corpus/6004-class-instance-integrity.ts new file mode 100644 index 000000000..7df516802 --- /dev/null +++ b/tests/corpus/6004-class-instance-integrity.ts @@ -0,0 +1,169 @@ +export {}; +import { inspect } from "node:util"; + +const message = (run: () => void): string => { + try { + run(); + return "no error"; + } catch (e) { + return `${e instanceof TypeError ? "TypeError" : "Error"}: ${(e as Error).message}`; + } +}; + +class Point { + x = 1; + y = 2; + label = "p"; + move(dx: number): void { + this.x += dx; + } +} + +const p = new Point(); +console.log(Object.isFrozen(p), Object.isSealed(p), Object.isExtensible(p)); +const same = Object.freeze(p); +console.log(same === p, Object.isFrozen(p), Object.isSealed(p), Object.isExtensible(p)); +console.log(message(() => { + p.x = 5; +})); +console.log(message(() => p.move(1))); +console.log(p.x, Object.keys(p)); +const q = new Point(); +q.x = 9; +console.log(q.x, Object.isFrozen(q)); + +class Point3 extends Point { + z = 3; +} +const p3 = new Point3(); +Object.freeze(p3); +console.log(message(() => { + p3.z = 1; +})); +const asBase: Point = new Point3(); +Object.freeze(asBase); +console.log(message(() => { + asBase.y = 0; +})); +console.log(asBase.y); + +class Counter { + n = 0; +} +const c = Object.seal(new Counter()); +c.n = 5; +console.log(c.n, Object.isSealed(c), Object.isFrozen(c), Object.isExtensible(c)); +const d = new Counter(); +Object.preventExtensions(d); +d.n = 2; +console.log(d.n, Object.isExtensible(d), Object.isSealed(d), Object.isFrozen(d)); +const loose: any = d; +console.log(message(() => { + loose.extra = 1; +})); + +class Config { + mode = "dev"; + level = 1; +} +const cfg = new Config(); +Object.defineProperty(cfg, "mode", { value: "prod", writable: false }); +console.log(cfg.mode, Object.keys(cfg), Object.values(cfg)); +console.log(message(() => { + cfg.mode = "x"; +})); +Object.defineProperty(cfg, "level", { enumerable: false }); +console.log(Object.keys(cfg), Object.entries(cfg), cfg.level); +Object.defineProperty(cfg, "level", { configurable: false }); +console.log(message(() => Object.defineProperty(cfg, "level", { enumerable: true }))); +console.log(JSON.stringify(Object.getOwnPropertyDescriptor(cfg, "mode"))); +console.log(JSON.stringify(Object.getOwnPropertyDescriptor(cfg, "level"))); +cfg.level = 7; +console.log(cfg.level); + +const fz = Object.freeze(new Config()); +console.log(message(() => Object.defineProperty(fz, "level", { value: 2 }))); +console.log(message(() => Object.defineProperty(fz, "level", { value: 1 }))); +console.log(fz.level, JSON.stringify(Object.getOwnPropertyDescriptor(fz, "level"))); + +class Frozen { + a = 1; + constructor() { + Object.freeze(this); + } +} +const frozenInCtor = new Frozen(); +console.log(frozenInCtor.a, Object.isFrozen(frozenInCtor)); +console.log(message(() => { + frozenInCtor.a = 2; +})); + +class Extended extends Frozen { + b = 2; +} +console.log(message(() => new Extended())); + +class Empty {} +const empty = new Empty(); +console.log(Object.isExtensible(empty), Object.isSealed(empty), Object.isFrozen(empty)); +Object.preventExtensions(empty); +console.log(Object.isExtensible(empty), Object.isSealed(empty), Object.isFrozen(empty)); + +class Totals { + count = 1; + items: string[] = []; +} +const t = new Totals(); +Object.freeze(t); +console.log(message(() => { + t.count += 1; +})); +console.log(message(() => { + t.count++; +})); +console.log(message(() => { + ({ value: t.count } = { value: 5 }); +})); +t.items.push("still mutable"); +console.log(t.count, t.items, Object.isFrozen(t.items)); +console.log(JSON.stringify(Object.getOwnPropertyDescriptor(t, "count"))); + +class Holder { + value: T; + constructor(value: T) { + this.value = value; + } +} +const h = Object.freeze(new Holder("text")); +console.log(h.value, Object.isFrozen(h)); +const h2 = new Holder(4); +Object.seal(h2); +h2.value = 5; +console.log(h2.value, Object.isSealed(h2)); + +class Settings { + mode = "dev"; + level = 1; +} +const settings = new Settings(); +console.log(settings); +Object.defineProperty(settings, "level", { enumerable: false }); +console.log(settings, inspect(settings)); +console.log([settings], { settings }); + +class Pair { + x = 1; + y = 2; +} +const pair = new Pair(); +Object.freeze(pair); +console.log(pair, Object.isFrozen(pair)); +const sealed = new Pair(); +(sealed as any).extra = "added"; +Object.seal(sealed); +console.log(sealed, Object.isSealed(sealed)); + +// JSON.stringify reads the same view: non-enumerable fields are skipped. +console.log(JSON.stringify(cfg), JSON.stringify(settings)); +console.log(JSON.stringify({ settings, pair, sealed })); +console.log(JSON.stringify([fz, t])); diff --git a/tests/corpus/6005-class-instance-integrity-sloppy.cjs b/tests/corpus/6005-class-instance-integrity-sloppy.cjs new file mode 100644 index 000000000..92c959d34 --- /dev/null +++ b/tests/corpus/6005-class-instance-integrity-sloppy.cjs @@ -0,0 +1,40 @@ +class Box { + constructor() { + this.x = 1; + } + bump() { + this.x = 2; + } +} + +const b = new Box(); +Object.freeze(b); +b.x = 5; +console.log(b.x, Object.isFrozen(b)); + +function strictWrite() { + "use strict"; + try { + b.x = 6; + } catch (e) { + console.log(e instanceof TypeError, e.message); + } +} +strictWrite(); + +try { + b.bump(); +} catch (e) { + console.log("class body:", e.message); +} +console.log(b.x); + +const s = new Box(); +Object.seal(s); +s.x = 3; +console.log(s.x, Object.isSealed(s), Object.isFrozen(s)); + +const plain = new Box(); +Object.defineProperty(plain, "x", { writable: false }); +plain.x = 9; +console.log(plain.x, JSON.stringify(Object.getOwnPropertyDescriptor(plain, "x"))); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 733f4b967..1d4f04d42 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2226,6 +2226,8 @@ "6001-json-stringify-js-class-instances.js": 4668, "6002-inspect-class-added-properties.ts": 6044, "6003-inspect-js-class-properties.js": 2752, + "6004-class-instance-integrity.ts": 10548, + "6005-class-instance-integrity-sloppy.cjs": 7466, "601-closures-loops.ts": 1788, "6010-match-metadata.ts": 3990, "6011-match-metadata-js.js": 3660, @@ -5311,6 +5313,8 @@ "6001-json-stringify-js-class-instances.js": 7469, "6002-inspect-class-added-properties.ts": 9670, "6003-inspect-js-class-properties.js": 4403, + "6004-class-instance-integrity.ts": 16877, + "6005-class-instance-integrity-sloppy.cjs": 11946, "601-closures-loops.ts": 2297, "6010-match-metadata.ts": 5450, "6011-match-metadata-js.js": 4890, From 48259bd7cd9d79b71bad2e62c4d7a7b0ef51f125 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 05:41:16 -0500 Subject: [PATCH 07/85] Refuse literal accessor descriptors for declared class fields at compile time Object.defineProperty with an object-literal descriptor that declares get or set for a declared class field is refused at compile time with SC2020, naming the field. JavaScript sources report it as a deferred fence, so coverage lists the call. Other accessor descriptors still throw SC2020 when the call runs. The native descriptor test now compiles a data descriptor change on a declared field and compares the output with Node, and checks that an accessor descriptor stays fenced. --- .../builtin-apis/object-freezing.mdx | 2 +- .../src/frontend/lowering/class-attributes.ts | 28 +++++++++++++- .../test/native-class-descriptors.test.ts | 38 ++++++++++++++++++- .../class-field-accessor-descriptor.ts.json | 6 +++ .../class-field-accessor-descriptor.ts | 14 +++++++ .../class-field-accessor-descriptor.ts.txt | 6 +++ 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-field-accessor-descriptor.ts.json create mode 100644 tests/diagnostics/class-field-accessor-descriptor.ts create mode 100644 tests/harness/__snapshots__/class-field-accessor-descriptor.ts.txt diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 473f21b0e..00070e773 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -1 +1 @@ -- **Object freezing:** `Object.freeze` and `Object.isFrozen` support plain checked dictionaries and primitives. Freezing is shallow and preserves accessors. Aliased typed records, arrays, proxies, and other native objects cannot be frozen. Class instances support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible` when the static type of the argument names the class. A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an accessor descriptor for a declared field throws `SC2020`. Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. A JavaScript class field that is first created after its instance became non-extensible is added instead of throwing. +- **Object freezing:** `Object.freeze` and `Object.isFrozen` support plain checked dictionaries and primitives. Freezing is shallow and preserves accessors. Aliased typed records, arrays, proxies, and other native objects cannot be frozen. Class instances support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible` when the static type of the argument names the class. A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. A JavaScript class field that is first created after its instance became non-extensible is added instead of throwing. diff --git a/packages/compiler/src/frontend/lowering/class-attributes.ts b/packages/compiler/src/frontend/lowering/class-attributes.ts index e3f4250a0..ae4dd4900 100644 --- a/packages/compiler/src/frontend/lowering/class-attributes.ts +++ b/packages/compiler/src/frontend/lowering/class-attributes.ts @@ -24,7 +24,7 @@ import { import { numLit, strLit, varRef } from "../../ir/build.js"; import { transformStmtList } from "../../ir/traverse.js"; import { locOf } from "../program.js"; -import type { Lowerer } from "./lowerer.js"; +import { type Lowerer, PoisonError } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; import { runtimeRooted } from "./class-descriptors.js"; @@ -132,10 +132,26 @@ export function lowerClassIntegrity( }; } +/** An object literal with a get or set property. */ +function literalAccessorDescriptor(node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return ( + ts.isObjectLiteralExpression(node) && + node.properties.some( + (property) => + property.name !== undefined && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && + (property.name.text === "get" || property.name.text === "set"), + ) + ); +} + /** Object.defineProperty with a string-literal key naming a declared field * of a class instance: a data descriptor changes the field's value and * attributes, validated as Node validates ordinary objects. Answers the - * instance, or null when the call is not of that form. */ + * instance, or null when the call is not of that form. An object-literal + * accessor descriptor is refused at compile time; other accessor + * descriptors throw SC2020 when the call runs. */ export function lowerClassFieldDefine( lowerer: Lowerer, call: ts.CallExpression, @@ -152,6 +168,14 @@ export function lowerClassFieldDefine( [...(info.symbolFields?.values() ?? [])].includes(field) ) return null; + if (literalAccessorDescriptor(call.arguments[2]!)) { + lowerer.pushDiag({ + code: "SC2020", + message: `Object.defineProperty with an accessor descriptor for the declared field '${field}' is not supported yet`, + loc: locOf(call), + }); + throw new PoisonError(); + } const getRecord = (id: string) => lowerer.shapes.get(id); const getUnion = (id: string) => lowerer.unions.get(id); if ( diff --git a/packages/compiler/test/native-class-descriptors.test.ts b/packages/compiler/test/native-class-descriptors.test.ts index d4aff3949..bd0a06dac 100644 --- a/packages/compiler/test/native-class-descriptors.test.ts +++ b/packages/compiler/test/native-class-descriptors.test.ts @@ -12,7 +12,7 @@ function fixture(source: string) { } test.each([ - "Object.defineProperty(value, 'x', {value: 2, writable: false});", + "Object.defineProperty(value, 'x', {get() { return 2; }});", "Object.defineProperties(value, {x: {value: 2}});", ])("keeps unsafe native descriptor changes fenced: %s", (operation) => { const { dir, entry } = fixture( @@ -30,6 +30,42 @@ test.each([ } }); +test("data descriptor changes on native fields match Node", async () => { + const { dir, entry } = fixture(` +class Value { constructor() { this.x = 1; } } +const value = new Value(); +Object.defineProperty(value, 'x', { value: 2, writable: false }); +console.log(value.x, JSON.stringify(Object.getOwnPropertyDescriptor(value, 'x'))); +value.x = 3; +console.log(value.x, Object.keys(value)); +`); + try { + const result = await compile(entry, { + outDir: dir, + outPath: join(dir, "program"), + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const run = spawnSync(result.binaryPath, { encoding: "utf8", timeout: 30_000 }); + const oracle = spawnSync(process.execPath, [entry], { encoding: "utf8", timeout: 30_000 }); + expect(run.error).toBeUndefined(); + expect(oracle.status).toBe(0); + expect({ + status: run.status, + signal: run.signal, + stdout: run.stdout, + stderr: run.stderr, + }).toEqual({ + status: oracle.status, + signal: oracle.signal, + stdout: oracle.stdout, + stderr: oracle.stderr, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("JS overrides with different return types match Node", async () => { const { dir, entry } = fixture(` class Base { copy(value = 1) { return value; } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-field-accessor-descriptor.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-field-accessor-descriptor.ts.json new file mode 100644 index 000000000..3d6639169 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-field-accessor-descriptor.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/class-field-accessor-descriptor.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/class-field-accessor-descriptor.ts b/tests/diagnostics/class-field-accessor-descriptor.ts new file mode 100644 index 000000000..66078f6c2 --- /dev/null +++ b/tests/diagnostics/class-field-accessor-descriptor.ts @@ -0,0 +1,14 @@ +// Object.defineProperty can change a declared class field's value and +// attributes, but not turn it into an accessor: a literal get or set +// descriptor is refused at compile time, naming the field. +class Value { + x = 1; +} + +const value = new Value(); +Object.defineProperty(value, "x", { + get() { + return 2; + }, +}); +console.log(value.x); diff --git a/tests/harness/__snapshots__/class-field-accessor-descriptor.ts.txt b/tests/harness/__snapshots__/class-field-accessor-descriptor.ts.txt new file mode 100644 index 000000000..986e0d023 --- /dev/null +++ b/tests/harness/__snapshots__/class-field-accessor-descriptor.ts.txt @@ -0,0 +1,6 @@ +class-field-accessor-descriptor.ts:9:1 - error SC2020: Object.defineProperty with an accessor descriptor for the declared field 'x' is not supported yet + + 8 | const value = new Value(); + 9 | Object.defineProperty(value, "x", { + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 10 | get() { \ No newline at end of file From a57089aebff45eb7d4fa7aa4bcc24d58ef8630e9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 07:51:54 -0500 Subject: [PATCH 08/85] Accept readline's dispose, instanceof, dynamic streams and non-string prompts rl[Symbol.dispose]() closes the interface, as Node's dispose method calls close(). rl instanceof readline.Interface and readlinePromises.Interface answer which module created the interface: rl.create's mode marks readline/promises interfaces, and the fallback declarations declare both Interface classes as values. In JavaScript, createInterface's input and output can be checked-dynamic values (a function's result, an object's member, an element that may be undefined): rl.inputFromDyn and rl.outputFromDyn unbox the stream they hold, an output of null or undefined means no output, and other values are refused. In JavaScript, question() and setPrompt() accept a value that is not a string. When such a prompt is written, an interface that is not a terminal throws ERR_INVALID_ARG_TYPE as Node's kWriteToOutput does, and the readline/promises question rejects with it; the question stays pending and the previous prompt returns when it is answered. A terminal interface writes String(value) of a primitive. Corpus 5876-5878 match Node v24.22.0. --- .../builtin-apis/streams-and-readline.mdx | 2 +- .../generated/node-v24-backlog.json | 78 +++++++--- .../generated/node-v24-internal.json | 83 +++++++--- internal/compatibility/static-support.json | 8 +- .../ambient/scriptc-node-fallback.d.ts | 10 +- .../compiler/src/backend/llvm/lib-dispatch.ts | 35 +++-- .../compiler/src/backend/llvm/lib-shared.ts | 4 + .../frontend/lowering/builtins/readline.ts | 123 +++++++++++++-- .../src/frontend/lowering/lower-calls.ts | 3 + .../src/frontend/lowering/lower-exprs.ts | 4 +- packages/compiler/src/ir/builtin-effects.ts | 6 + .../compiler/src/ir/builtin-signatures.ts | 17 ++ packages/compiler/src/ir/runtime-features.ts | 7 + .../5876-readline-dispose-instanceof.ts.json | 6 + .../5877-readline-question-query.cjs.json | 6 + .../5878-readline-dynamic-streams.mjs.json | 6 + packages/runtime/src/scr_readline.c | 147 ++++++++++++++++-- packages/runtime/src/scr_readline.h | 16 +- packages/runtime/src/scr_readline_internal.h | 14 ++ packages/runtime/src/scr_readline_tty.c | 8 +- packages/runtime/src/scr_stream.c | 25 +++ .../5876-readline-dispose-instanceof.ts | 28 ++++ tests/corpus/5877-readline-question-query.cjs | 51 ++++++ .../corpus/5878-readline-dynamic-streams.mjs | 41 +++++ tests/harness/corpus-costs.json | 6 + 25 files changed, 647 insertions(+), 87 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5876-readline-dispose-instanceof.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5877-readline-question-query.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5878-readline-dynamic-streams.mjs.json create mode 100644 tests/corpus/5876-readline-dispose-instanceof.ts create mode 100644 tests/corpus/5877-readline-question-query.cjs create mode 100644 tests/corpus/5878-readline-dynamic-streams.mjs diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index c48f04bcc..14735a9a1 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -1 +1 @@ -- **Streams and readline:** Stream chunks are Buffers or strings. `Readable.from` accepts arrays, Sets, Map and Set iterators, generators, async generators, and class iterables and async iterables whose values are strings or Buffers, and in JavaScript any iterable or async iterable of them; a stream that mixes strings and Buffers is unsupported. In JavaScript, a sync iterator value with a `then` method that is not a promise, and an object whose `[Symbol.asyncIterator]` or `[Symbol.iterator]` method is a generator method, are unsupported. Object-mode streams of other values (including `Readable.from` over a `Map`, whose values are entry arrays) are unsupported. `pipeline` accepts one run of function stages: async generator functions between streams (`async function* (source)`, iterating `source` with `for await`), an async generator source function (`async function* ({ signal })`), and an async function destination whose value resolves the promise form and reaches the callback's second argument. Stages may take the `{ signal }` argument. An async generator function as the destination, using the PassThrough that `pipeline()` returns for a function destination, several runs of function stages, and function stages together with pipeline options are unsupported. `Readable.from` accepts `objectMode` and `highWaterMark` options. The `Readable` operators (`map`, `filter`, `forEach`, `toArray`, `some`, `find`, `every`, `flatMap`, `drop`, `take`, and `reduce`) accept `concurrency`, `highWaterMark`, and `signal` options; their callbacks take only the chunk, `map` returns strings or Buffers, and `flatMap` returns strings or arrays of them. `readable.compose` is unsupported. `stream.compose` accepts two or more streams, or one `Duplex`; functions, iterables, and web streams as stages are unsupported, and the `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream. `pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags. Stream constructors accept a `signal` option in an options object literal, and `stream.addAbortSignal` accepts any stream. `node:readline` and `node:readline/promises` (also reached as `readline.promises`) interfaces, from `createInterface(options)` or `createInterface(input, output, completer, terminal)`, read `process.stdin` or a readable stream: `question`, `prompt`, `write` (with a key object literal of `name`, `ctrl`, `meta`, and `shift`), `pause`, `resume`, `'line'`/`'close'`/`'pause'`/`'resume'`/`'error'`/`'history'`/`'SIGINT'`/`'SIGTSTP'`/`'SIGCONT'` listeners, and `for await` compile. The `history`, `historySize`, `removeHistoryDuplicates`, `completer`, `tabSize`, and `escapeCodeTimeout` options are validated as in Node. With `terminal: true`, or with no `terminal` option and `process.stdout` as the output when it is a TTY, the interface runs Node's terminal mode: the editing keys, the kill ring, undo and redo, history recording and recall, Tab completion through the completer, Ctrl+C, Ctrl+D, and Ctrl+Z, `TERM=dumb`, and the same escape sequences written to the output. The output's `'resize'` event does not redraw the line. The cursor never stops between the two UTF-16 units of a supplementary character: where Node's Up or Down movement through a multiline line would, the cursor moves to the character's start or end. In JavaScript, a `'history'` listener receives a copy of the history; its changes are written back when the listener returns, but the copy is not `rl.history` itself. `rl.history`, `historySize`, `historyIndex`, `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `cursor`, and `terminal` read as in Node, except that `rl.cursor` of an interface that is not a terminal throws instead of answering `undefined`. `readline.cursorTo`, `moveCursor`, `clearLine`, and `clearScreenDown` write to `process.stdout`, `process.stderr`, a writable stream, or nothing for `null` and `undefined`; their callbacks take no parameters. The `readline/promises` `Readline` class writes to the same outputs. `readline.emitKeypressEvents` emits `'keypress'` events on `process.stdin` or a readable stream, shared with terminal interfaces on that stream as in Node; a listener's first parameter must admit `undefined`, which it receives for escape sequences. Chained listener registration is unsupported. +- **Streams and readline:** Stream chunks are Buffers or strings. `Readable.from` accepts arrays, Sets, Map and Set iterators, generators, async generators, and class iterables and async iterables whose values are strings or Buffers, and in JavaScript any iterable or async iterable of them; a stream that mixes strings and Buffers is unsupported. In JavaScript, a sync iterator value with a `then` method that is not a promise, and an object whose `[Symbol.asyncIterator]` or `[Symbol.iterator]` method is a generator method, are unsupported. Object-mode streams of other values (including `Readable.from` over a `Map`, whose values are entry arrays) are unsupported. `pipeline` accepts one run of function stages: async generator functions between streams (`async function* (source)`, iterating `source` with `for await`), an async generator source function (`async function* ({ signal })`), and an async function destination whose value resolves the promise form and reaches the callback's second argument. Stages may take the `{ signal }` argument. An async generator function as the destination, using the PassThrough that `pipeline()` returns for a function destination, several runs of function stages, and function stages together with pipeline options are unsupported. `Readable.from` accepts `objectMode` and `highWaterMark` options. The `Readable` operators (`map`, `filter`, `forEach`, `toArray`, `some`, `find`, `every`, `flatMap`, `drop`, `take`, and `reduce`) accept `concurrency`, `highWaterMark`, and `signal` options; their callbacks take only the chunk, `map` returns strings or Buffers, and `flatMap` returns strings or arrays of them. `readable.compose` is unsupported. `stream.compose` accepts two or more streams, or one `Duplex`; functions, iterables, and web streams as stages are unsupported, and the `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream. `pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags. Stream constructors accept a `signal` option in an options object literal, and `stream.addAbortSignal` accepts any stream. `node:readline` and `node:readline/promises` (also reached as `readline.promises`) interfaces, from `createInterface(options)` or `createInterface(input, output, completer, terminal)`, read `process.stdin` or a readable stream: `question`, `prompt`, `write` (with a key object literal of `name`, `ctrl`, `meta`, and `shift`), `pause`, `resume`, `'line'`/`'close'`/`'pause'`/`'resume'`/`'error'`/`'history'`/`'SIGINT'`/`'SIGTSTP'`/`'SIGCONT'` listeners, and `for await` compile. The `history`, `historySize`, `removeHistoryDuplicates`, `completer`, `tabSize`, and `escapeCodeTimeout` options are validated as in Node. With `terminal: true`, or with no `terminal` option and `process.stdout` as the output when it is a TTY, the interface runs Node's terminal mode: the editing keys, the kill ring, undo and redo, history recording and recall, Tab completion through the completer, Ctrl+C, Ctrl+D, and Ctrl+Z, `TERM=dumb`, and the same escape sequences written to the output. The output's `'resize'` event does not redraw the line. The cursor never stops between the two UTF-16 units of a supplementary character: where Node's Up or Down movement through a multiline line would, the cursor moves to the character's start or end. In JavaScript, a `'history'` listener receives a copy of the history; its changes are written back when the listener returns, but the copy is not `rl.history` itself. `rl.history`, `historySize`, `historyIndex`, `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `cursor`, and `terminal` read as in Node, except that `rl.cursor` of an interface that is not a terminal throws instead of answering `undefined`. `rl[Symbol.dispose]()` closes the interface, and `instanceof` with `readline.Interface` or `readlinePromises.Interface` answers whether the interface came from that module, as in Node. In JavaScript, the input and output can be any expression that evaluates to a stream, such as a function's result or an object's member; a value that is not a stream is unsupported. In JavaScript, `question()` and `setPrompt()` accept a value that is not a string: when such a prompt is written, an interface that is not a terminal throws `ERR_INVALID_ARG_TYPE` as Node does (the `readline/promises` question rejects with it) and the question stays pending, and a terminal interface writes `String(value)`. A terminal interface's prompt that is an object or a symbol, `getPrompt()` while the prompt is not a string, and `getCursorPos()` with an object or symbol prompt are unsupported. `readline.cursorTo`, `moveCursor`, `clearLine`, and `clearScreenDown` write to `process.stdout`, `process.stderr`, a writable stream, or nothing for `null` and `undefined`; their callbacks take no parameters. The `readline/promises` `Readline` class writes to the same outputs. `readline.emitKeypressEvents` emits `'keypress'` events on `process.stdin` or a readable stream, shared with terminal interfaces on that stream as in Node; a listener's first parameter must admit `undefined`, which it receives for escape sequences. Chained listener registration is unsupported. diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 0ad30b783..b62a17cd9 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -58997,7 +58997,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59029,7 +59031,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59257,7 +59261,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59318,7 +59324,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59350,7 +59358,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59433,7 +59443,8 @@ "confidence": "high", "source": "compiler-dedicated:readline.createInterface", "tests": [ - "tests/corpus/1475-readline-closed-stdin.ts" + "tests/corpus/1475-readline-closed-stdin.ts", + "tests/corpus/5878-readline-dynamic-streams.mjs" ] } } @@ -59572,7 +59583,8 @@ "confidence": "high", "source": "compiler-dedicated:rl.close", "tests": [ - "tests/corpus/1475-readline-closed-stdin.ts" + "tests/corpus/1475-readline-closed-stdin.ts", + "tests/corpus/5878-readline-dynamic-streams.mjs" ] } } @@ -59602,7 +59614,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59632,7 +59646,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59662,7 +59678,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59692,7 +59710,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59722,7 +59742,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59752,7 +59774,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59782,7 +59806,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59812,7 +59838,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59932,7 +59960,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59962,7 +59992,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -59992,7 +60024,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -60050,7 +60084,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } @@ -60080,7 +60116,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] } } diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 2f0565f84..ee139efa0 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -82445,7 +82445,10 @@ "tests/corpus/5791-readline-keypress-interfaces.cjs", "tests/corpus/5792-readline-keypress-listeners.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs", + "tests/corpus/5878-readline-dynamic-streams.mjs" ] }, "anchorSource": "chapter" @@ -82499,7 +82502,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -82530,7 +82535,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -82750,7 +82757,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -82832,7 +82841,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -82863,7 +82874,9 @@ "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", - "tests/corpus/5873-readline-promises-namespace.cjs" + "tests/corpus/5873-readline-promises-namespace.cjs", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -82943,7 +82956,8 @@ "status": "partial", "evidence": "compiler-dedicated:readline.createInterface", "tests": [ - "tests/corpus/1475-readline-closed-stdin.ts" + "tests/corpus/1475-readline-closed-stdin.ts", + "tests/corpus/5878-readline-dynamic-streams.mjs" ] }, "anchorSource": "exact" @@ -83099,7 +83113,8 @@ "status": "partial", "evidence": "compiler-dedicated:rl.close", "tests": [ - "tests/corpus/1475-readline-closed-stdin.ts" + "tests/corpus/1475-readline-closed-stdin.ts", + "tests/corpus/5878-readline-dynamic-streams.mjs" ] }, "anchorSource": "exact" @@ -83128,7 +83143,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83157,7 +83174,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83186,7 +83205,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83215,7 +83236,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83244,7 +83267,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83273,7 +83298,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83302,7 +83329,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83331,7 +83360,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83447,7 +83478,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83476,7 +83509,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83505,7 +83540,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83561,7 +83598,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" @@ -83590,7 +83629,9 @@ "tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", - "tests/corpus/5299-readline-stdin.ts" + "tests/corpus/5299-readline-stdin.ts", + "tests/corpus/5876-readline-dispose-instanceof.ts", + "tests/corpus/5877-readline-question-query.cjs" ] }, "anchorSource": "exact" diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 5807c1ae9..87a51470e 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -189,13 +189,13 @@ {"chapter": "events", "symbols": ["CustomEvent", "detail"], "status": "partial", "evidence": ["tests/corpus/5130-event-target-dispatch.js", "tests/corpus/5132-event-target-typed.ts"]}, {"chapter": "events", "symbols": ["events.errorMonitor"], "status": "partial", "evidence": ["tests/corpus/5134-events-error-monitor.ts", "tests/corpus/5136-events-error-monitor-string-name.ts", "tests/corpus/5137-events-error-monitor-computed-name.js", "tests/corpus/5138-events-error-monitor-shared-listener.ts"]}, {"chapter": "events", "symbols": ["events.addAbortListener"], "status": "partial", "evidence": ["tests/corpus/5133-abort-signal-event-target.ts"]}, - {"chapter": "readline", "symbols": ["readlinePromises.Interface", "readlinePromises.createInterface", "rl.question"], "status": "partial", "evidence": ["tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", "tests/corpus/5873-readline-promises-namespace.cjs"]}, + {"chapter": "readline", "symbols": ["readlinePromises.Interface", "readlinePromises.createInterface", "rl.question"], "status": "partial", "evidence": ["tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5298-readline-abort.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5872-readline-positional-create-interface.mjs", "tests/corpus/5873-readline-promises-namespace.cjs", "tests/corpus/5876-readline-dispose-instanceof.ts", "tests/corpus/5877-readline-question-query.cjs"]}, {"chapter": "readline", "symbols": ["'history'", "rl.line"], "status": "partial", "evidence": ["tests/corpus/5780-readline-history-options.ts", "tests/corpus/5781-readline-history-options.cjs"]}, {"chapter": "readline", "symbols": ["'SIGINT'", "'SIGTSTP'", "'SIGCONT'", "line", "cursor", "completer"], "status": "partial", "evidence": ["tests/corpus/5782-readline-terminal-editing.ts", "tests/corpus/5783-readline-terminal-completion.cjs", "tests/corpus/5784-readline-terminal-promises.ts", "tests/corpus/5785-readline-terminal-keys.ts", "tests/corpus/5786-readline-terminal-history.cjs"]}, {"chapter": "readline", "symbols": ["readline.cursorTo", "readline.moveCursor", "readline.clearLine", "readline.clearScreenDown", "rl.getCursorPos"], "status": "partial", "evidence": ["tests/corpus/5787-readline-cursor-helpers.cjs", "tests/corpus/5789-readline-cursor-pos.cjs"]}, {"chapter": "readline", "symbols": ["readlinePromises.Readline", "rl.clearLine", "rl.clearScreenDown", "rl.commit", "rl.cursorTo", "rl.moveCursor", "rl.rollback"], "status": "partial", "evidence": ["tests/corpus/5788-readline-promises-readline.ts", "tests/corpus/5789-readline-cursor-pos.cjs"]}, {"chapter": "readline", "symbols": ["readline.emitKeypressEvents"], "status": "partial", "evidence": ["tests/corpus/5790-readline-keypress-events.ts", "tests/corpus/5791-readline-keypress-interfaces.cjs", "tests/corpus/5792-readline-keypress-listeners.ts"]}, - {"chapter": "readline", "symbols": ["readline.Interface", "rl.pause", "rl.resume", "rl.prompt", "rl.setPrompt", "rl.getPrompt", "rl.write", "rl", "'close'", "'error'", "'line'", "'pause'", "'resume'"], "status": "partial", "evidence": ["tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", "tests/corpus/5299-readline-stdin.ts"]}, + {"chapter": "readline", "symbols": ["readline.Interface", "rl.pause", "rl.resume", "rl.prompt", "rl.setPrompt", "rl.getPrompt", "rl.write", "rl", "'close'", "'error'", "'line'", "'pause'", "'resume'"], "status": "partial", "evidence": ["tests/corpus/5295-readline-promises-question.ts", "tests/corpus/5296-readline-lines.ts", "tests/corpus/5297-readline-callback-events.cjs", "tests/corpus/5299-readline-stdin.ts", "tests/corpus/5876-readline-dispose-instanceof.ts", "tests/corpus/5877-readline-question-query.cjs"]}, {"chapter": "stream", "symbols": ["stream.addAbortSignal"], "status": "partial", "evidence": ["tests/corpus/5300-stream-add-abort-signal.ts", "tests/corpus/5301-stream-add-abort-signal-errors.cjs"]}, {"chapter": "stream", "symbols": ["readable.map", "readable.filter", "readable.forEach", "readable.toArray", "readable.some", "readable.find", "readable.every", "readable.flatMap", "readable.drop", "readable.take", "readable.reduce"], "status": "partial", "evidence": ["tests/corpus/5335-stream-operators-values.ts", "tests/corpus/5336-stream-operators-promise-timing.ts", "tests/corpus/5337-stream-operators-concurrency.ts", "tests/corpus/5338-stream-operators-checks.cjs"]}, {"chapter": "stream", "symbols": ["stream.Readable.from"], "status": "partial", "evidence": ["tests/corpus/5340-readable-from-options.ts", "tests/corpus/5341-readable-from-dynamic.cjs", "tests/corpus/5420-readable-from-generators.ts", "tests/corpus/5421-readable-from-generators.cjs", "tests/corpus/5610-readable-from-sets-and-views.ts", "tests/corpus/5611-readable-from-class-iterables.ts", "tests/corpus/5612-readable-from-iterables.cjs"]}, @@ -338,7 +338,7 @@ { "chapter": "fs", "signatures": ["`filehandle[Symbol.asyncDispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "fs", "signatures": ["`fsPromises.realpath(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2940-fsp-realpath.ts"] }, { "chapter": "fs", "signatures": ["`fsPromises.readdir(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2942-fsp-readdir-dirent.ts"] }, - { "chapter": "readline", "signatures": ["`rl[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, + { "chapter": "readline", "signatures": ["`rl[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts", "tests/corpus/5876-readline-dispose-instanceof.ts"] }, { "chapter": "timers", "signatures": ["`immediate[Symbol.dispose]()`", "`timeout[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "stream", "signatures": ["`readable[Symbol.asyncIterator]()`", "`readable.iterator([options])`"], "status": "partial", "evidence": ["tests/corpus/1746-stream-for-await.ts", "tests/corpus/2861-readable-iterator-close.ts"] }, { "chapter": "stream", "signatures": ["`writable.destroy([error])`"], "status": "partial", "evidence": ["tests/corpus/2897-child-stdin-destroy.ts"] }, @@ -405,7 +405,7 @@ { "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] }, { "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, { "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, - { "symbols": ["readline.createInterface", "rl.close"], "status": "partial", "evidence": ["tests/corpus/1475-readline-closed-stdin.ts"] }, + { "symbols": ["readline.createInterface", "rl.close"], "status": "partial", "evidence": ["tests/corpus/1475-readline-closed-stdin.ts", "tests/corpus/5878-readline-dynamic-streams.mjs"] }, { "symbols": ["test", "it", "test.skip", "test.todo", "test.only", "describe", "suite", "before", "after", "beforeEach", "afterEach", "context.test", "context.skip", "context.todo", "context.assert"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] }, { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] } ] diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 0a0837389..f8fafe010 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3839,7 +3839,10 @@ declare module "readline" { meta?: boolean; shift?: boolean; } - export interface Interface extends Disposable { + export class Interface implements Disposable { + /* createInterface creates interfaces; instanceof tells the two + * modules' classes apart. */ + protected constructor(); /* terminal says whether the interface edits lines as a terminal; * line and cursor are the terminal's (line is '' otherwise). history * is the history option itself (or the interface's own empty array); @@ -3954,7 +3957,10 @@ declare module "readline/promises" { /* The readline Interface with the promise question: it resolves with * the next line, rejects with an AbortError when options.signal aborts, * and with ERR_USE_AFTER_CLOSE after close(). */ - export interface Interface extends Disposable { + export class Interface implements Disposable { + /* createInterface creates interfaces; instanceof tells the two + * modules' classes apart. */ + protected constructor(); /* terminal says whether the interface edits lines as a terminal; * line and cursor are the terminal's (line is '' otherwise). history * is the history option itself (or the interface's own empty array); diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index daa8e08f3..2328839c9 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -82,12 +82,19 @@ export function emitIoLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue } // A signal that is not an AbortSignal throws ERR_INVALID_ARG_TYPE. if (mode & 8) host.emitPendingCheck(); + // readline/promises interfaces are instances of that module's class. + if (mode & 32) { + host.declare(`declare void @scr_rl_mark_promises(double)`); + B.line(`call void @scr_rl_mark_promises(double ${t})`); + } return { name: t, type: e.type }; } - if (e.fn === "rl.question") { + if (e.fn === "rl.question" || e.fn === "rl.questionDyn") { // The answer callback MOVES into the interface's registry; throws - // Node's use-after-close error (the may-throw seed). + // Node's use-after-close error (the may-throw seed), and with a + // checked-dynamic query that is not a string, ERR_INVALID_ARG_TYPE. host.usesTimers = true; + const fn = e.fn === "rl.question" ? "scr_rl_question" : "scr_rl_question_dyn"; const cbT = e.args[2]!.type; if (cbT.kind !== "func") throw new InternalCompilerError("llvm emitter bug: rl.question callback not a func"); @@ -100,29 +107,37 @@ export function emitIoLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue ? "scr_rl_answer_thunk_dyn" : "scr_rl_answer_thunk_str"; host.declare(`declare void @${adapter}(ptr, ptr)`); - host.declare(`declare void @scr_rl_question(double, ptr, ptr, ptr)`); + host.declare(`declare void @${fn}(double, ptr, ptr, ptr)`); B.line( - `call void @scr_rl_question(double ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${args[2]!.name}, ptr @${adapter})`, + `call void @${fn}(double ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${args[2]!.name}, ptr @${adapter})`, ); host.emitPendingCheck(); return { name: "", type: e.type }; } - if (e.fn === "rl.questionAsync" || e.fn === "rl.questionAsyncSignal") { - // The promise question never throws: closed interfaces and aborted - // signals reject the answered promise (+1). + if ( + e.fn === "rl.questionAsync" || + e.fn === "rl.questionAsyncSignal" || + e.fn === "rl.questionAsyncDyn" || + e.fn === "rl.questionAsyncSignalDyn" + ) { + // The promise question never throws: closed interfaces, aborted + // signals and queries that are not strings reject the answered + // promise (+1). host.usesTimers = true; const args = e.args.map((a) => host.emitExpr(a)); + const dyn = e.fn === "rl.questionAsyncDyn" || e.fn === "rl.questionAsyncSignalDyn"; + const fn = dyn ? "scr_rl_question_promise_dyn" : "scr_rl_question_promise"; let signal = "null"; let add = "null"; - if (e.fn === "rl.questionAsyncSignal") { + if (e.fn === "rl.questionAsyncSignal" || e.fn === "rl.questionAsyncSignalDyn") { signal = args[2]!.name; host.declare(`declare ptr @scr_events_add_abort_listener(ptr, ptr)`); add = "@scr_events_add_abort_listener"; } - host.declare(`declare ptr @scr_rl_question_promise(double, ptr, ptr, ptr)`); + host.declare(`declare ptr @${fn}(double, ptr, ptr, ptr)`); const t = B.tmp(); B.line( - `${t} = call ptr @scr_rl_question_promise(double ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${signal}, ptr ${add})`, + `${t} = call ptr @${fn}(double ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${signal}, ptr ${add})`, ); return host.own({ name: t, type: e.type }); } diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index e4c12058f..a713546a3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1023,6 +1023,7 @@ export const LIB_FN_SYMS: Record = { "rl.prompt": "scr_rl_prompt", "rl.promptWith": "scr_rl_prompt_with", "rl.setPrompt": "scr_rl_set_prompt", + "rl.setPromptDyn": "scr_rl_set_prompt_dyn", "rl.getPrompt": "scr_rl_get_prompt", "rl.write": "scr_rl_write", "rl.writeKey": "scr_rl_write_key", @@ -1038,6 +1039,9 @@ export const LIB_FN_SYMS: Record = { "rl.terminal": "scr_rl_terminal", "rl.line": "scr_rl_line", "rl.isTerminal": "scr_rl_is_terminal", + "rl.isPromises": "scr_rl_is_promises", + "rl.inputFromDyn": "scr_stream_rl_from_dyn", + "rl.outputFromDyn": "scr_stream_rl_from_dyn", "rl.cursor": "scr_rl_cursor", "rl.seq": "scr_rl_seq", "rl.moves": "scr_rl_moves", diff --git a/packages/compiler/src/frontend/lowering/builtins/readline.ts b/packages/compiler/src/frontend/lowering/builtins/readline.ts index 3a0e7ccf0..0c0da9a40 100644 --- a/packages/compiler/src/frontend/lowering/builtins/readline.ts +++ b/packages/compiler/src/frontend/lowering/builtins/readline.ts @@ -18,6 +18,7 @@ import { READLINE_DOCUMENTED_OPTIONS, fenceOrDropOptionKey } from "../surfaces.j import { streamSidesOf } from "../lower-stream.js"; import type { IrLibFn } from "../../../ir/builtin-signatures.js"; import { voidizedCallback } from "../lower-server.js"; +import { builtinImportOf } from "./module-bindings.js"; /** The ambient module declaring `d`, or null. */ function ambientModuleOf(d: ts.Node): string | null { @@ -270,6 +271,80 @@ function lowerReadlineUiCall( return { kind: "seqExpr", stmts, result: recv, type: F64, loc }; } +/** The module ("readline" or "readline/promises") whose Interface class + * `node` names, or null. */ +function interfaceClassModule(lowerer: Lowerer, node: ts.Expression): string | null { + const builtin = ts.isIdentifier(node) + ? builtinImportOf(lowerer, node) + : ts.isPropertyAccessExpression(node) && !node.questionDotToken + ? lowerer.builtinMemberOf(node) + : null; + if (builtin?.member !== "Interface") return null; + return builtin.module === "readline" || builtin.module === "readline/promises" + ? builtin.module + : null; +} + +/** `rl instanceof readline.Interface` and `rl instanceof + * readlinePromises.Interface` over a readline Interface: an interface is + * an instance of the class of the module that created it (Node's + * readline/promises Interface does not extend node:readline's). Null for + * other instanceof tests. */ +export function lowerReadlineInstanceof( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const module = interfaceClassModule(lowerer, expr.right); + if (module === null || !isReadlineTyped(lowerer, expr.left)) return null; + const loc = locOf(expr); + const recv = lowerer.lowerExprExpecting(expr.left, F64); + const isPromises: IrExpr = { + kind: "libCall", + fn: "rl.isPromises", + args: [recv], + type: BOOL, + loc, + }; + if (module === "readline/promises") return isPromises; + return { kind: "unary", op: "!", operand: isPromises, type: BOOL, loc }; +} + +/** `rl[Symbol.dispose]()` on a readline Interface: Node's dispose method + * calls close(), which does nothing on a closed interface. Null for other + * element-access calls. */ +export function lowerReadlineDisposeCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + const access = call.expression; + if (!ts.isElementAccessExpression(access) || access.questionDotToken || call.questionDotToken) { + return null; + } + let key = access.argumentExpression; + while (ts.isParenthesizedExpression(key)) key = key.expression; + if ( + !ts.isPropertyAccessExpression(key) || + lowerer.stdlibGlobalMember(key, "Symbol") !== "dispose" + ) { + return null; + } + if (!isReadlineTyped(lowerer, access.expression)) return null; + if (call.arguments.length > 0) { + lowerer.noLowering("readline.Interface[Symbol.dispose] with arguments", call); + } + const recv = lowerer.lowerExprExpecting(access.expression, F64); + return { kind: "libCall", fn: "rl.close", args: [recv], type: VOID, loc: locOf(call) }; +} + +/** A question query or setPrompt argument: the string, or for a value + * that need not be one (JavaScript), the checked-dynamic value, which the + * runtime writes the way Node's kWriteToOutput does (dyn: true). */ +function lowerPromptArg(lowerer: Lowerer, node: ts.Expression): { value: IrExpr; dyn: boolean } { + if (lowerer.typeOf(node).flags & ts.TypeFlags.StringLike) { + return { value: lowerer.lowerExprExpecting(node, STRING), dyn: false }; + } + const value = lowerer.lowerExpr(node); + if (value.type.kind === "string") return { value, dyn: false }; + return { value: lowerer.coerceInto(node, value, DYN), dyn: true }; +} + /** Method calls on readline Interface receivers (both modules): * - question(query, cb) — the callback form; question(query, { signal }?) * — the promise form (readline/promises); @@ -303,18 +378,20 @@ export function lowerReadlineMethodCall( args.length <= 2 ) { const recv = receiver(); - const query = lowerer.lowerExprExpecting(args[0]!, STRING); + const query = lowerPromptArg(lowerer, args[0]!); const signal = questionSignal(lowerer, args[1]); const type = { kind: "promise", inner: STRING } as const; if (signal === null) { - return { kind: "libCall", fn: "rl.questionAsync", args: [recv, query], type, loc }; + const fn = query.dyn ? "rl.questionAsyncDyn" : "rl.questionAsync"; + return { kind: "libCall", fn, args: [recv, query.value], type, loc }; } const sig = lowerer.lowerExprExpecting(signal, DYN); - return { kind: "libCall", fn: "rl.questionAsyncSignal", args: [recv, query, sig], type, loc }; + const fn = query.dyn ? "rl.questionAsyncSignalDyn" : "rl.questionAsyncSignal"; + return { kind: "libCall", fn, args: [recv, query.value, sig], type, loc }; } if (name === "question" && args.length === 2) { const recv = receiver(); - const query = lowerer.lowerExprExpecting(args[0]!, STRING); + const query = lowerPromptArg(lowerer, args[0]!); const cb = lowerer.lowerExpr(args[1]!); if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.unsupported( @@ -331,7 +408,8 @@ export function lowerReadlineMethodCall( `question callbacks whose parameter is not 'string' (got '${lowerer.fmt(param)}')`, ); } - return { kind: "libCall", fn: "rl.question", args: [recv, query, cb], type: VOID, loc }; + const fn = query.dyn ? "rl.questionDyn" : "rl.question"; + return { kind: "libCall", fn, args: [recv, query.value, cb], type: VOID, loc }; } const simple: Record = { close: "rl.close", @@ -366,8 +444,9 @@ export function lowerReadlineMethodCall( } if (name === "setPrompt" && args.length === 1) { const recv = receiver(); - const prompt = lowerer.lowerExprExpecting(args[0]!, STRING); - return { kind: "libCall", fn: "rl.setPrompt", args: [recv, prompt], type: VOID, loc }; + const prompt = lowerPromptArg(lowerer, args[0]!); + const fn = prompt.dyn ? "rl.setPromptDyn" : "rl.setPrompt"; + return { kind: "libCall", fn, args: [recv, prompt.value], type: VOID, loc }; } if (name === "getPrompt" && args.length === 0) { return { kind: "libCall", fn: "rl.getPrompt", args: [receiver()], type: STRING, loc }; @@ -871,6 +950,25 @@ export function lowerReadlineCursorCall( return { kind: "seqExpr", stmts, result, type: BOOL, loc }; } +/** A checked-dynamic input or output (a JavaScript function's result, an + * object's member, an element that may be undefined) as the stream it + * holds: the runtime refuses a value that is not a stream with that + * side, and answers null for an output of null or undefined. Other + * values pass through. */ +function streamFromDyn(lowerer: Lowerer, node: ts.Expression, output: boolean): IrExpr { + let v = lowerer.lowerExpr(node); + if (v.type.kind === "union") v = lowerer.coerceInto(node, v, DYN); + if (v.type.kind !== "dyn") return v; + const loc = locOf(node); + return { + kind: "libCall", + fn: output ? "rl.outputFromDyn" : "rl.inputFromDyn", + args: [v, boolLit(output, loc)], + type: { kind: "object", className: output ? "%Writable" : "%Readable" }, + loc, + }; +} + /** The interface's input: process.stdin (null) or a readable stream. */ function lowerInput(lowerer: Lowerer, node: ts.Expression): IrExpr | null { if ( @@ -879,7 +977,7 @@ function lowerInput(lowerer: Lowerer, node: ts.Expression): IrExpr | null { ) { return null; } - const v = lowerer.lowerExpr(node); + const v = streamFromDyn(lowerer, node, false); const info = v.type.kind === "object" ? lowerer.classes.get(v.type.className) : undefined; const sides = streamSidesOf(lowerer, info); if (sides !== "r" && sides !== "rw") { @@ -904,7 +1002,7 @@ function lowerOutput(lowerer: Lowerer, node: ts.Expression): IrExpr | "stdout" | return "none"; } if (ts.isIdentifier(node) && node.text === "undefined") return "none"; - const v = lowerer.lowerExpr(node); + const v = streamFromDyn(lowerer, node, true); const info = v.type.kind === "object" ? lowerer.classes.get(v.type.className) : undefined; const sides = streamSidesOf(lowerer, info); if (sides !== "w" && sides !== "rw") { @@ -923,8 +1021,8 @@ function lowerOutput(lowerer: Lowerer, node: ts.Expression): IrExpr | "stdout" | * stream; the output process.stdout, a writable stream, or absent * (prompts are then not written). rl.create's arguments: the mode literal * (1 stream input, 2 stream output, 4 stdout output, 8 signal, 16 - * prompt), then the input and output streams when present, crlfDelay, - * the prompt and the signal when present. An interface that can be a + * prompt, 32 node:readline/promises), then the input and output streams + * when present, crlfDelay, the prompt and the signal when present. An interface that can be a * terminal one (a `terminal` option that is not false, or none with * process.stdout as the output, which is a terminal when it is a TTY) * goes through rl.terminal with the completer. */ @@ -1107,7 +1205,8 @@ export function lowerReadlineCreateCall( (typeof output === "object" ? 2 : 0) | (output === "stdout" ? 4 : 0) | (signal !== null ? 8 : 0) | - (prompt !== null ? 16 : 0); + (prompt !== null ? 16 : 0) | + (promises ? 32 : 0); const args: IrExpr[] = [numLit(mode, loc)]; if (input !== null) args.push(input); if (typeof output === "object") args.push(output); diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 67ca01b3f..7d538dac0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -34,6 +34,7 @@ import { import { cjsExportCallIgnoresThis, cjsInFileExportGlobalOf } from "./cjs-exports-object.js"; import { lowerGenMethodCall } from "./lower-generators.js"; import { lowerClassSymbolMethodCall } from "./symbol-methods.js"; +import { lowerReadlineDisposeCall } from "./builtins/readline.js"; import { errorToStringCall } from "./error-methods.js"; import { lowerClassPrototypeAssign, lowerClassPrototypeDescriptors } from "./class-prototypes.js"; import { @@ -1648,6 +1649,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (timersDispose) return timersDispose; const symbolMethod = lowerClassSymbolMethodCall(lowerer, expr); if (symbolMethod) return symbolMethod; + const readlineDispose = lowerReadlineDisposeCall(lowerer, expr); + if (readlineDispose) return readlineDispose; lowerer.fenceUnsupportedFetchConstructorMember(expr.expression); lowerer.fenceStaticResponseMember(expr.expression, "call"); lowerer.fenceStaticHeadersMember(expr.expression, "call"); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 09407f71f..e7f6ed5d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -9,7 +9,7 @@ import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.j import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { esmImportBindingKind } from "./builtins/module-bindings.js"; -import { lowerReadlineProperty } from "./builtins/readline.js"; +import { lowerReadlineInstanceof, lowerReadlineProperty } from "./builtins/readline.js"; import { lowerBlobInstanceof } from "./builtins/blob.js"; import { lowerWebAssemblyTypeof, refuseWebAssemblyUse } from "./builtins/webassembly.js"; import { lowerKeyObjectInstanceof } from "./builtins/key-object.js"; @@ -12816,6 +12816,8 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc if (keyObject) return keyObject; const netAddress = lowerNetAddressInstanceof(lowerer, expr); if (netAddress) return netAddress; + const readlineInterface = lowerReadlineInstanceof(lowerer, expr); + if (readlineInterface) return readlineInterface; } if (lowerer.isStdlibGlobal(expr.right, "SharedArrayBuffer")) { const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index fc4c383ff..82a463ae8 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -435,6 +435,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "https.requestFn", "https.requestFnCb", "rl.question", + "rl.questionDyn", "rl.checkOptions", "rl.checkCompleter", "readable.fromArr", @@ -457,6 +458,11 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "rl.resume", "rl.prompt", "rl.promptWith", + "rl.setPromptDyn", + "rl.getPrompt", + "rl.cursorPos", + "rl.inputFromDyn", + "rl.outputFromDyn", "rl.write", "rl.writeKey", "rl.cursor", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index df5bf0091..e85c45641 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3959,6 +3959,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ argTypes: [F64, STRING, DYN], result: { kind: "promise", inner: STRING }, }, + /** The question forms with a checked-dynamic query (a JavaScript value + * that need not be a string). */ + "rl.questionDyn": { argTypes: [F64, DYN, null], result: VOID }, + "rl.questionAsyncDyn": { argTypes: [F64, DYN], result: { kind: "promise", inner: STRING } }, + "rl.questionAsyncSignalDyn": { + argTypes: [F64, DYN, DYN], + result: { kind: "promise", inner: STRING }, + }, "rl.close": { argTypes: [F64], result: VOID }, "rl.pause": { argTypes: [F64], result: VOID }, "rl.resume": { argTypes: [F64], result: VOID }, @@ -3966,6 +3974,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** prompt(preserveCursor): [handle, preserveCursor] */ "rl.promptWith": { argTypes: [F64, BOOL], result: VOID }, "rl.setPrompt": { argTypes: [F64, STRING], result: VOID }, + "rl.setPromptDyn": { argTypes: [F64, DYN], result: VOID }, "rl.getPrompt": { argTypes: [F64], result: STRING }, "rl.write": { argTypes: [F64, STRING], result: VOID }, /** write(data, key): [handle, hasData, data, hasKey, hasName, name, @@ -3998,6 +4007,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "rl.terminal": { argTypes: [F64, F64, BOOL, DYN, BOOL], result: VOID }, "rl.line": { argTypes: [F64], result: STRING }, "rl.isTerminal": { argTypes: [F64], result: BOOL }, + "rl.isPromises": { argTypes: [F64], result: BOOL }, + /** createInterface's input or output as a checked-dynamic value: + * [value, output] — the stream; refused when it is not one. */ + "rl.inputFromDyn": { argTypes: [DYN, BOOL], result: { kind: "object", className: "%Readable" } }, + "rl.outputFromDyn": { + argTypes: [DYN, BOOL], + result: { kind: "object", className: "%Writable" }, + }, "rl.cursor": { argTypes: [F64], result: F64 }, /** readline.cursorTo / moveCursor / clearLine / clearScreenDown: rl.seq * [kind (0-3), the first and second numbers, whether the second is diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 6e71fe91c..5fcb66055 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -192,6 +192,8 @@ const STREAM_LIB_FNS: ReadonlySet = new Set([ "fileHandle.createWriteStream", "fs.createReadStream", "fs.createWriteStream", + "rl.inputFromDyn", + "rl.outputFromDyn", "sp.finished", "sp.pipeline", "sp.pipelineStages", @@ -663,12 +665,16 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "rl.question", "rl.questionAsync", "rl.questionAsyncSignal", + "rl.questionDyn", + "rl.questionAsyncDyn", + "rl.questionAsyncSignalDyn", "rl.close", "rl.pause", "rl.resume", "rl.prompt", "rl.promptWith", "rl.setPrompt", + "rl.setPromptDyn", "rl.getPrompt", "rl.write", "rl.writeKey", @@ -685,6 +691,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "rl.terminal", "rl.line", "rl.isTerminal", + "rl.isPromises", "rl.cursor", "rl.seq", "rl.moves", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5876-readline-dispose-instanceof.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5876-readline-dispose-instanceof.ts.json new file mode 100644 index 000000000..959c704b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5876-readline-dispose-instanceof.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5876-readline-dispose-instanceof.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5877-readline-question-query.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5877-readline-question-query.cjs.json new file mode 100644 index 000000000..fe2bcfeee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5877-readline-question-query.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5877-readline-question-query.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5878-readline-dynamic-streams.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5878-readline-dynamic-streams.mjs.json new file mode 100644 index 000000000..ddd665241 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5878-readline-dynamic-streams.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5878-readline-dynamic-streams.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_readline.c b/packages/runtime/src/scr_readline.c index e031a4e34..1bc48bbe7 100644 --- a/packages/runtime/src/scr_readline.c +++ b/packages/runtime/src/scr_readline.c @@ -185,11 +185,28 @@ void scr_rl_do_resume(ScrRl *rl) { } } +bool scr_rl_prompt_writable(ScrRl *rl) { + if (!rl->prompt_value) return true; + scr_dyn_arg_type_fail("stringToWrite", "of type string", rl->prompt_value); + return false; +} + static void scr_rl_prompt_now(ScrRl *rl) { if (rl->paused) scr_rl_do_resume(rl); if (scr_exc_pending()) return; if (rl->tty) scr_rl_tty_ops->prompt(rl, false); - else scr_rl_write_out(rl, rl->prompt); + else if (scr_rl_prompt_writable(rl)) scr_rl_write_out(rl, rl->prompt); +} + +/* setPrompt(this[kOldPrompt]) when a question is answered or cancelled */ +static void scr_rl_restore_prompt(ScrRl *rl) { + if (!rl->old_prompt) return; + scr_str_release(rl->prompt); + rl->prompt = rl->old_prompt; + rl->old_prompt = NULL; + if (rl->prompt_value) scr_dyn_release(rl->prompt_value); + rl->prompt_value = rl->old_prompt_value; + rl->old_prompt_value = NULL; } /* ── close ─────────────────────────────────────────────────────────── */ @@ -251,11 +268,7 @@ void scr_rl_on_line(ScrRl *rl, ScrStr *line) { ScrClosure *cb = rl->q_cb; ScrRlLineFn fn = rl->q_fn; rl->q_cb = NULL; - if (rl->old_prompt) { - scr_str_release(rl->prompt); - rl->prompt = rl->old_prompt; - rl->old_prompt = NULL; - } + scr_rl_restore_prompt(rl); fn(cb, line); scr_closure_release(cb); return; @@ -619,6 +632,8 @@ static void scr_rl_cleanup_atexit(void) { if (rl->out) rl->io->release(rl->out); scr_str_release(rl->prompt); if (rl->old_prompt) scr_str_release(rl->old_prompt); + if (rl->prompt_value) scr_dyn_release(rl->prompt_value); + if (rl->old_prompt_value) scr_dyn_release(rl->old_prompt_value); free(rl->line_buf); free(rl->parked); free(rl); @@ -748,7 +763,58 @@ void scr_rl_question(double id, ScrStr *query, ScrClosure *cb /*moves*/, ScrRlLi return; } rl->old_prompt = rl->prompt; + rl->old_prompt_value = rl->prompt_value; rl->prompt = scr_str_retain(query); + rl->prompt_value = NULL; + rl->q_cb = cb; + rl->q_fn = fn; + scr_rl_prompt_now(rl); +} + +/* A prompt that is not a string: String(value) for a primitive, which is + * what the terminal's line refresh and getCursorPos concatenate. An + * object's valueOf/toString would run on every refresh, and a symbol + * throws there, so a terminal interface refuses them (false, the refusal + * pending); another interface only writes the prompt, which throws for + * any value that is not a string, so it keeps ''. +1. */ +static bool scr_rl_prompt_text(ScrRl *rl, ScrDyn *value, ScrStr **out) { + if (scr_rl_primitive_prompt(value)) { + *out = scr_dyn_string_coerce(value); + return true; + } + if (!rl->tty) { + *out = scr_str_new("", 0); + return true; + } + static const char m[] = + "a terminal readline prompt that is an object or a symbol has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, m, sizeof m - 1, "SC2020"); + return false; +} + +/* question(query, cb) with a checked-dynamic query: a string goes through + * scr_rl_question. Another value becomes the prompt, the callback is + * kept, and the prompt write throws ERR_INVALID_ARG_TYPE on an interface + * that is not a terminal (the question stays pending, as in Node). */ +void scr_rl_question_dyn(double id, ScrDyn *query, ScrClosure *cb /*moves*/, ScrRlLineFn fn) { + if (query->kind == SCR_DYN_STR) { + scr_rl_question(id, query->v.str, cb, fn); + return; + } + ScrRl *rl = scr_rl_find(id); + if (!rl || rl->closed || rl->q_cb) { + scr_rl_question(id, NULL, cb, fn); /* the closed throw, or the re-prompt */ + return; + } + ScrStr *text; + if (!scr_rl_prompt_text(rl, query, &text)) { + scr_closure_release(cb); + return; + } + rl->old_prompt = rl->prompt; + rl->old_prompt_value = rl->prompt_value; + rl->prompt = text; + rl->prompt_value = scr_dyn_retain(query); rl->q_cb = cb; rl->q_fn = fn; scr_rl_prompt_now(rl); @@ -857,11 +923,7 @@ static ScrDyn *scr_rl_ask_abort_thunk(ScrClosure *clo, ScrDyn *const *args, size if (rl && rl->q_cb) { scr_closure_release(rl->q_cb); rl->q_cb = NULL; - if (rl->old_prompt) { - scr_str_release(rl->prompt); - rl->prompt = rl->old_prompt; - rl->old_prompt = NULL; - } + scr_rl_restore_prompt(rl); if (rl->tty) { scr_rl_tty_ops->clear_line(rl); } else { @@ -877,8 +939,11 @@ static ScrDyn *scr_rl_ask_abort_thunk(ScrClosure *clo, ScrDyn *const *args, size /* readline/promises question(query, { signal }): rejects with * ERR_USE_AFTER_CLOSE on a closed interface and with an AbortError when - * the signal is (or becomes) aborted. */ -ScrPromise *scr_rl_question_promise(double id, ScrStr *query, ScrDyn *signal, ScrAbortAddFn add) { + * the signal is (or becomes) aborted. A checked-dynamic query (query_dyn, + * query NULL) that is not a string rejects with what question() throws, + * as Node's promise executor does. */ +static ScrPromise *scr_rl_ask(double id, ScrStr *query, ScrDyn *query_dyn, ScrDyn *signal, + ScrAbortAddFn add) { RlAsk *a = calloc(1, sizeof *a); if (!a) scr_rl_oom(); a->rc = 1; @@ -928,10 +993,29 @@ ScrPromise *scr_rl_question_promise(double id, ScrStr *query, ScrDyn *signal, Sc scr_box_set_ref(cb->caps[0], a); /* the creation reference moves here */ cb->caps[1] = scr_box_new_obj(&scr_rl_dyn_retain_v, &scr_rl_dyn_release_v, NULL); scr_box_set_ref(cb->caps[1], dispose); /* moves (NULL without a signal) */ - scr_rl_question(id, query, cb, &scr_rl_ask_answer); + if (!query_dyn) { + scr_rl_question(id, query, cb, &scr_rl_ask_answer); + return result; + } + scr_rl_ask_retain_v(a); + scr_rl_question_dyn(id, query_dyn, cb, &scr_rl_ask_answer); + if (scr_exc_pending() && !a->settled) { + a->settled = true; + scr_promise_reject_pending(a->p); + } + scr_rl_ask_release_v(a); return result; } +ScrPromise *scr_rl_question_promise(double id, ScrStr *query, ScrDyn *signal, ScrAbortAddFn add) { + return scr_rl_ask(id, query, NULL, signal, add); +} + +ScrPromise *scr_rl_question_promise_dyn(double id, ScrDyn *query, ScrDyn *signal, + ScrAbortAddFn add) { + return scr_rl_ask(id, NULL, query, signal, add); +} + void scr_rl_close(double id) { ScrRl *rl = scr_rl_find(id); if (rl) scr_rl_settle_close(rl); /* twice is a no-op, like Node */ @@ -985,13 +1069,48 @@ void scr_rl_set_prompt(double id, ScrStr *prompt) { if (!rl) return; scr_str_release(rl->prompt); rl->prompt = scr_str_retain(prompt); + if (rl->prompt_value) scr_dyn_release(rl->prompt_value); + rl->prompt_value = NULL; +} + +/* setPrompt(value) with a checked-dynamic value; borrows. */ +void scr_rl_set_prompt_dyn(double id, ScrDyn *prompt) { + if (prompt->kind == SCR_DYN_STR) { + scr_rl_set_prompt(id, prompt->v.str); + return; + } + ScrRl *rl = scr_rl_find(id); + if (!rl) return; + ScrStr *text; + if (!scr_rl_prompt_text(rl, prompt, &text)) return; + scr_str_release(rl->prompt); + rl->prompt = text; + if (rl->prompt_value) scr_dyn_release(rl->prompt_value); + rl->prompt_value = scr_dyn_retain(prompt); } ScrStr *scr_rl_get_prompt(double id) { ScrRl *rl = scr_rl_find(id); + if (rl && rl->prompt_value) { + static const char m[] = "rl.getPrompt() of a prompt that is not a string has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, m, sizeof m - 1, "SC2020"); + return scr_str_new("", 0); + } return rl ? scr_str_retain(rl->prompt) : scr_str_new("", 0); } +/* Called after a node:readline/promises interface opens: its Interface + * class is not node:readline's, which instanceof tells apart. */ +void scr_rl_mark_promises(double id) { + ScrRl *rl = scr_rl_find(id); + if (rl) rl->promises = true; +} + +bool scr_rl_is_promises(double id) { + ScrRl *rl = scr_rl_find(id); + return rl && rl->promises; +} + bool scr_rl_is_terminal(double id) { ScrRl *rl = scr_rl_find(id); return rl && rl->tty; diff --git a/packages/runtime/src/scr_readline.h b/packages/runtime/src/scr_readline.h index 9385f6dd7..4557dcb4d 100644 --- a/packages/runtime/src/scr_readline.h +++ b/packages/runtime/src/scr_readline.h @@ -47,13 +47,22 @@ void scr_rl_input_error(double id, ScrError *err); void scr_rl_question(double id, ScrStr *query, ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrStr *)); ScrPromise *scr_rl_question_promise(double id, ScrStr *query, ScrDyn *signal, ScrAbortAddFn add); +/* The same with a checked-dynamic query (borrowed). A query that is not a + * string makes the prompt write throw ERR_INVALID_ARG_TYPE (the promise + * form rejects with it) unless the interface is a terminal; the question + * stays pending, as in Node. */ +void scr_rl_question_dyn(double id, ScrDyn *query, ScrClosure *cb /*moves*/, + void (*fn)(ScrClosure *, ScrStr *)); +ScrPromise *scr_rl_question_promise_dyn(double id, ScrDyn *query, ScrDyn *signal, + ScrAbortAddFn add); void scr_rl_close(double id); void scr_rl_pause(double id); /* may throw ERR_USE_AFTER_CLOSE */ void scr_rl_resume(double id); /* may throw ERR_USE_AFTER_CLOSE */ void scr_rl_prompt(double id); /* may throw ERR_USE_AFTER_CLOSE */ void scr_rl_prompt_with(double id, bool preserve); /* prompt(preserveCursor); may throw */ void scr_rl_set_prompt(double id, ScrStr *prompt); -ScrStr *scr_rl_get_prompt(double id); /* +1 */ +void scr_rl_set_prompt_dyn(double id, ScrDyn *prompt); /* borrows; may throw */ +ScrStr *scr_rl_get_prompt(double id); /* +1; may throw (a prompt that is not a string) */ void scr_rl_write(double id, ScrStr *data); /* may throw ERR_USE_AFTER_CLOSE */ void scr_rl_on(double id, double ev, ScrClosure *cb /*moves*/, void *fn, bool once); /* createInterface's history and editing options, in Node's order: the @@ -83,6 +92,8 @@ double scr_rl_num_prop(double id, double which); * when there is a completer (the inspect unit links only then). */ void scr_rl_terminal(double id, double mode, bool on, ScrDyn *completer, bool promises, ScrStr *(*inspect)(ScrDyn *, double, double)); +void scr_rl_mark_promises(double id); +bool scr_rl_is_promises(double id); bool scr_rl_is_terminal(double id); /* readline.cursorTo / moveCursor / clearLine / clearScreenDown: the data * written (+1; kind 0-3), after the argument checks (may throw), and @@ -135,6 +146,9 @@ void scr_rl_error_thunk_dyn(ScrClosure *cb, ScrError *err); * the input stream or NULL for process.stdin; out the output stream or * NULL (out_stdout picks process.stdout). Both borrowed, like prompt * (NULL: the default "> "). */ +/* A checked-dynamic input or output as its stream (+1; NULL for an output + * of null or undefined); refuses other values (may throw). */ +ScrStream *scr_stream_rl_from_dyn(const ScrDyn *value, bool output); double scr_stream_rl_open(ScrStream *in, ScrStream *out, bool out_stdout, double crlf_delay, ScrStr *prompt, ScrDyn *signal, ScrAbortAddFn add); void scr_stdin_data_thunk0(ScrClosure *cb, ScrBytes *chunk); diff --git a/packages/runtime/src/scr_readline_internal.h b/packages/runtime/src/scr_readline_internal.h index 0fe2e2cb2..49ef42f39 100644 --- a/packages/runtime/src/scr_readline_internal.h +++ b/packages/runtime/src/scr_readline_internal.h @@ -56,11 +56,17 @@ typedef struct ScrRl { bool stdin_in; /* reads process.stdin through the shared consumer */ bool dead; /* stdin only: created after stdin ended */ bool paused; + bool promises; /* created by node:readline/promises */ const ScrRlIo *io; /* the stream side, for a stream input or output */ void *in; /* the input stream (+1), or NULL */ void *out; /* the output stream (+1), or NULL */ bool out_stdout; ScrStr *prompt, *old_prompt; + /* A prompt that is not a string (setPrompt or question with another + * value), +1, or NULL; prompt then holds String(value) for a terminal + * interface. Writing it the way Node's kWriteToOutput does throws + * ERR_INVALID_ARG_TYPE. old_prompt_value is old_prompt's. */ + ScrDyn *prompt_value, *old_prompt_value; ScrClosure *q_cb; /* the pending question's callback (owned) */ ScrRlLineFn q_fn; /* the last promise question (kQuestionReject), which the terminal's @@ -114,6 +120,14 @@ ScrArr *scr_rl_history_of(ScrRl *rl); /* borrowed; created on first use */ void scr_rl_emit(ScrRl *rl, int ev, ScrStr *line, ScrError *err); void scr_rl_emit_history(ScrRl *rl); void scr_rl_write_out(ScrRl *rl, ScrStr *text); +/* kWriteToOutput(prompt)'s validateString: false with ERR_INVALID_ARG_TYPE + * pending when the prompt is not a string. */ +bool scr_rl_prompt_writable(ScrRl *rl); +/* A prompt value whose String() the interface keeps in prompt. */ +static inline bool scr_rl_primitive_prompt(const ScrDyn *v) { + return v->kind == SCR_DYN_NULL || v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_BOOL || + v->kind == SCR_DYN_NUM || v->kind == SCR_DYN_BIGINT; +} /* kOnLine: a pending question takes the line, else 'line'. Moves line. */ void scr_rl_on_line(ScrRl *rl, ScrStr *line); void scr_rl_do_pause(ScrRl *rl); diff --git a/packages/runtime/src/scr_readline_tty.c b/packages/runtime/src/scr_readline_tty.c index 38271411f..1c5fcd5f6 100644 --- a/packages/runtime/src/scr_readline_tty.c +++ b/packages/runtime/src/scr_readline_tty.c @@ -2255,7 +2255,7 @@ static void tty_prompt(ScrRl *rl, bool preserve) { bool dumb = term && term->len == 4 && memcmp(term->data, "dumb", 4) == 0; if (term) scr_str_release(term); if (dumb) { - scr_rl_write_out(rl, rl->prompt); + if (scr_rl_prompt_writable(rl)) scr_rl_write_out(rl, rl->prompt); return; } if (!preserve) t->cursor = 0; @@ -2333,6 +2333,12 @@ double scr_rl_cursor_pos(double id, double which) { if (rl->tty) { pos = tty_cursor_pos(rl); } else { + if (rl->prompt_value && !scr_rl_primitive_prompt(rl->prompt_value)) { + static const char m[] = + "rl.getCursorPos() with a prompt that is an object or a symbol has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, m, sizeof m - 1, "SC2020"); + return 0; + } ScrRlTty probe = {0}; probe.columns = INFINITY; rl->tty = &probe; diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 244e2ec5d..6087228a1 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -4728,6 +4728,31 @@ static void scr_stream_rl_hooks_free(void) { } } +/* createInterface's input or output given as a checked-dynamic value (a + * JavaScript function's result, an object's member): the stream (+1). + * An output of null or undefined is NULL (no output). A value that is + * not a stream with a readable (input) or writable (output) side is + * refused. */ +ScrStream *scr_stream_rl_from_dyn(const ScrDyn *value, bool output) { + if (output && (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF)) return NULL; + if (value->kind == SCR_DYN_TYPED_REF && value->v.typed_ref.type_key_len > 7 && + !memcmp(value->v.typed_ref.type_key, "object:", 7)) { + ScrStream *stream = scr_dyn_typed_ref_unbox(value); + const ScrVt *vt = stream->vt; + bool duplex = vt == &scr_duplex_vt || vt == &scr_transform_vt || vt == &scr_passthrough_vt || + scr_vt_within(vt, &scr_duplex_vt); + bool ok = duplex || (output ? vt == &scr_writable_vt || scr_vt_within(vt, &scr_writable_vt) + : vt == &scr_readable_vt || scr_vt_within(vt, &scr_readable_vt)); + if (ok) return stream; + value->v.typed_ref.release(stream); + } + static const char in_m[] = "createInterface with an input that is not a readable stream has no lowering"; + static const char out_m[] = "createInterface with an output that is not a writable stream has no lowering"; + if (output) scr_throw_error_msg_code(SCR_ERR_ERROR, out_m, sizeof out_m - 1, "SC2020"); + else scr_throw_error_msg_code(SCR_ERR_ERROR, in_m, sizeof in_m - 1, "SC2020"); + return NULL; +} + double scr_stream_rl_open(ScrStream *in, ScrStream *out, bool out_stdout, double crlf_delay, ScrStr *prompt, ScrDyn *signal, ScrAbortAddFn add) { double id = scr_rl_open(&scr_stream_rl_io, in ? scr_stream_retain(in) : NULL, diff --git a/tests/corpus/5876-readline-dispose-instanceof.ts b/tests/corpus/5876-readline-dispose-instanceof.ts new file mode 100644 index 000000000..63f058e9b --- /dev/null +++ b/tests/corpus/5876-readline-dispose-instanceof.ts @@ -0,0 +1,28 @@ +// rl[Symbol.dispose]() closes the interface as close() does, and a second +// call does nothing. instanceof tells node:readline's Interface from +// node:readline/promises' one. +import * as readline from "node:readline"; +import * as readlinePromises from "node:readline/promises"; +import { PassThrough } from "node:stream"; + +const rl = readline.createInterface({ input: new PassThrough() }); +rl.on("close", () => console.log("close 1")); +rl[Symbol.dispose](); +console.log("disposed"); +rl[Symbol.dispose](); +rl.close(); +console.log("after second dispose"); + +const prl = readlinePromises.createInterface({ input: new PassThrough() }); +prl.on("close", () => console.log("close 2")); +console.log(rl instanceof readline.Interface, rl instanceof readlinePromises.Interface); +console.log(prl instanceof readline.Interface, prl instanceof readlinePromises.Interface); +prl[Symbol.dispose](); + +function make(): readline.Interface { + return readline.createInterface({ input: new PassThrough() }); +} +const made = make(); +made.on("close", () => console.log("close 3")); +made[Symbol.dispose](); +console.log("done"); diff --git a/tests/corpus/5877-readline-question-query.cjs b/tests/corpus/5877-readline-question-query.cjs new file mode 100644 index 000000000..734311b16 --- /dev/null +++ b/tests/corpus/5877-readline-question-query.cjs @@ -0,0 +1,51 @@ +// question() with a query that is not a string: a non-terminal interface +// throws (callback form) or rejects (readline/promises) with Node's +// ERR_INVALID_ARG_TYPE from the output write, and the question stays +// pending; a terminal interface writes the query as a string. +const readline = require("readline"); +const readlinePromises = require("readline/promises"); +const { PassThrough } = require("stream"); + +function capture(name) { + const output = new PassThrough(); + output.on("data", (d) => console.log(name, JSON.stringify(String(d)))); + return output; +} + +async function main() { + const input = new PassThrough(); + const rl = readline.createInterface({ input, output: capture("out") }); + rl.on("line", (line) => console.log("line", line)); + try { + rl.question(42, (answer) => console.log("answer", answer)); + } catch (e) { + console.log(e.name, e.code, e.message); + } + try { + rl.prompt(); + } catch (e) { + console.log("prompt", e.code, e.message); + } + input.write("first\nsecond\n"); + await new Promise((resolve) => setImmediate(resolve)); + console.log("prompt after", JSON.stringify(rl.getPrompt())); + rl.close(); + + for (const query of [undefined, null, { a: 1 }, [1, 2], true]) { + const prl = readlinePromises.createInterface({ input: new PassThrough() }); + try { + await prl.question(query); + } catch (e) { + console.log("rejected", e.code, e.message); + } + prl.close(); + } + + const tin = new PassThrough(); + const trl = readline.createInterface({ input: tin, output: capture("tty"), terminal: true }); + trl.question(7, (answer) => console.log("tty answer", answer)); + tin.write("x\r"); + await new Promise((resolve) => setImmediate(resolve)); + trl.close(); +} +main(); diff --git a/tests/corpus/5878-readline-dynamic-streams.mjs b/tests/corpus/5878-readline-dynamic-streams.mjs new file mode 100644 index 000000000..b1b9f4ee5 --- /dev/null +++ b/tests/corpus/5878-readline-dynamic-streams.mjs @@ -0,0 +1,41 @@ +// createInterface over an input or output that is a function's result, +// an object's member or an array element. +import * as readline from "node:readline"; +import * as readlinePromises from "node:readline/promises"; +import { PassThrough, Readable, Writable } from "node:stream"; + +function source(lines) { + return Readable.from([lines.join("\n") + "\n"]); +} +const streams = { input: source(["a", "b"]), output: new PassThrough() }; +streams.output.on("data", (d) => console.log("out", JSON.stringify(String(d)))); + +const rl1 = readline.createInterface({ input: source(["one", "two"]) }); +for await (const line of rl1) console.log("rl1", line); + +const rl2 = readline.createInterface({ input: streams.input, output: streams.output, prompt: "? " }); +rl2.prompt(); +for await (const line of rl2) console.log("rl2", line); + +const inputs = [source(["x"]), source(["y"])]; +for (const [i, input] of inputs.entries()) { + const rl = readline.createInterface(input); + rl.on("line", (line) => console.log("rl3", i, line)); + await new Promise((resolve) => { + rl.on("close", () => resolve(undefined)); + }); +} + +const sink = new Writable({ + write(chunk, _encoding, callback) { + console.log("sink", JSON.stringify(String(chunk))); + callback(); + }, +}); +const make = () => ({ input: new PassThrough(), output: sink }); +const opts = make(); +const prl = readlinePromises.createInterface(opts.input, opts.output); +const answer = prl.question("name? "); +opts.input.write("ada\n"); +console.log("answer", await answer); +prl.close(); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 62703f6a4..b4d0ff3aa 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2129,6 +2129,9 @@ "5873-readline-promises-namespace.cjs": 2023, "5874-worker-stdio-streams.ts": 2839, "5875-worker-stdout-pipe.cjs": 2740, + "5876-readline-dispose-instanceof.ts": 2465, + "5877-readline-question-query.cjs": 3233, + "5878-readline-dynamic-streams.mjs": 2925, "5879-worker-stdin.ts": 3158, "5880-emitter-leak-warning-delivery.ts": 3418, "5881-inspect-weak-collections.ts": 2615, @@ -5218,6 +5221,9 @@ "5873-readline-promises-namespace.cjs": 3811, "5874-worker-stdio-streams.ts": 27801, "5875-worker-stdout-pipe.cjs": 31391, + "5876-readline-dispose-instanceof.ts": 29361, + "5877-readline-question-query.cjs": 170940, + "5878-readline-dynamic-streams.mjs": 25503, "5879-worker-stdin.ts": 33232, "5880-emitter-leak-warning-delivery.ts": 4319, "5881-inspect-weak-collections.ts": 2915, From 88a2c557d1ad8d1d95e07c3b58a36c722d1f3448 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 07:59:51 -0500 Subject: [PATCH 09/85] Link the child-process unit only in programs that spawn The event loop called scr_children_pending, scr_children_poll and the other child hooks directly on native targets, so every async program linked the child-process unit (the reap pass, IPC service, pipe readers and writers). The loop now reaches them through a nullable ScrLoopChildren table that the child unit installs with its first spawned child or IPC channel, the shape the events, net, dgram and fs.watch hooks already use. The WASI stubs for the old direct calls are removed. --- packages/runtime/src/scr_async.c | 49 +++++++++------------- packages/runtime/src/scr_child.c | 24 +++++++++++ packages/runtime/src/scr_child.h | 14 +++++++ tests/harness/runtime-tree-shaking.test.ts | 27 ++++++++++++ 4 files changed, 85 insertions(+), 29 deletions(-) diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 88ad0f7bd..155f91be4 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -56,24 +56,6 @@ #include #endif -#ifdef __wasi__ -/* WASI Preview 1 has no process-spawn capability. The child unit is not - * linked for this target, but the generic loop keeps its quiescence hooks; - * their empty implementation makes the absence explicit at link time - * without weakening async/timer support. */ -bool scr_children_pending(void) { return false; } -bool scr_children_ready(void) { return false; } -bool scr_children_reffed_pending(void) { return false; } -bool scr_children_failed_pending(void) { return false; } -void scr_children_poll(void) {} -int scr_children_wake_fd(void) { return -1; } -bool scr_children_wait(double max_wait_ms) { - (void)max_wait_ms; - return false; -} -void scr_children_teardown(void) {} -#endif - #if defined(__has_feature) #if __has_feature(address_sanitizer) #define SCR_ASAN_FIBERS 1 @@ -4072,6 +4054,13 @@ void scr_loop_set_watch(bool (*pending)(void), void (*dispatch)(void), int (*pol scr_watch_pollfd_fn = pollfd; } +/* The child-process hook (scr_child.c, installed with the first spawned + * child or IPC channel). Programs that never create one keep it NULL and + * do not link the child unit; WASI never links it. */ +static SCR_TL const ScrLoopChildren *scr_loop_children = NULL; + +void scr_loop_set_children(const ScrLoopChildren *hooks) { scr_loop_children = hooks; } + /* The foreign-FFI queue hook (scr_ffi.c when a format-5 descriptor exists). * Its pending count includes live registrations (reffed by default) and * queued deliveries; dispatch runs exactly one callback per loop turn. */ @@ -4326,12 +4315,13 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * death landing before this sweep fired its 'exit' where Node * deterministically never does. The teardown below releases the * undelivered listeners, so the RC audit stays clean. */ - if (scr_children_pending()) { + if (scr_loop_children != NULL && scr_loop_children->pending()) { bool held = - scr_reffed_timers > 0 || scr_reffed_immediates > 0 || scr_children_reffed_pending() || + scr_reffed_timers > 0 || scr_reffed_immediates > 0 || + scr_loop_children->reffed_pending() || /* spawn failures settle regardless: their 'error' is a * next-tick event, delivered even on an unref'd child */ - scr_children_failed_pending() || + scr_loop_children->failed_pending() || (scr_events_pending_fn != NULL && scr_events_pending_fn()) || (scr_net_pending_fn != NULL && scr_net_pending_fn()) || (scr_dgram_pending_fn != NULL && scr_dgram_pending_fn()) || @@ -4340,7 +4330,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { scr_fs_renames_pending() || scr_crypto_bytes_pending() || scr_loop_deferred_pending() || scr_workers_pending(); if (held) { - scr_children_poll(); + scr_loop_children->poll(); if (scr_exc_pending()) return false; /* uncaught throw in a listener */ if (scr_ready_len > 0 || scr_nt_head != NULL) continue; } @@ -4351,7 +4341,8 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * nextTicks enqueued by a station listener are the same story. */ if (scr_stream_pending_fn != NULL && scr_stream_pending_fn()) continue; if (scr_nt_head != NULL) continue; - bool kids = scr_children_pending(); + bool kids = scr_loop_children != NULL && scr_loop_children->pending(); + bool kids_reffed = scr_loop_children != NULL && scr_loop_children->reffed_pending(); bool events = scr_events_pending_fn != NULL && scr_events_pending_fn(); bool net = scr_net_pending_fn != NULL && scr_net_pending_fn(); /* `net` keeps the process alive; `net_io` (unref'd handles too) decides @@ -4369,7 +4360,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * Children follow the same rule: an unref'd child is still REAPED * while the loop runs (kids drives the sweeps and sleeps above) but * only reffed ones keep the process alive. */ - if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !scr_children_reffed_pending() && !events && !net && !dgram && !watch && !ffi && !renames && !crypto && !workers) break; + if (scr_reffed_timers == 0 && scr_reffed_immediates == 0 && !kids_reffed && !events && !net && !dgram && !watch && !ffi && !renames && !crypto && !workers) break; /* Sleep to the earliest deadline, then run every due timer (each may * enqueue microtasks, which the next iteration drains first). Who * sleeps depends on what is pending: @@ -4413,7 +4404,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * station has run. Completed sends and disconnects can also become ready * during that turn. No fd will wake us for this userspace work: return to * dispatch without sleeping, still allowing due timers and immediates. */ - if (scr_children_ready()) due = now; + if (scr_loop_children != NULL && scr_loop_children->ready()) due = now; #ifdef SCR_WORKERS if (scr_workers_ready_fn && scr_workers_ready_fn()) due = now; #endif @@ -4522,7 +4513,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { } } if (kids) { - int cfd = scr_children_wake_fd(); + int cfd = scr_loop_children->wake_fd(); if (cfd >= 0) { fds[nfds].fd = cfd; fds[nfds].events = POLLIN; @@ -4553,10 +4544,10 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * unconsumed event keeps the kqueue fd readable and would turn the * next poll into a busy spin while other children still run. The * WNOHANG sweep at the next turn's top stays the reaper. */ - if (kids) (void)scr_children_wait(0); + if (kids) (void)scr_loop_children->wait(0); now = scr_now_ms(); #endif /* !_WIN32 && !__wasi__ */ - } else if (kids && scr_children_wait(due > now ? due - now : 0)) { + } else if (kids && scr_loop_children->wait(due > now ? due - now : 0)) { now = scr_now_ms(); /* woke early on an exit, or hit the deadline */ } else { if (kids && due > now + SCR_CHILD_POLL_MS) due = now + SCR_CHILD_POLL_MS; @@ -4710,7 +4701,7 @@ bool scr_loop_run(ScrPromise *top_level) { /* Unref'd children the loop never reaped: release the * registry's references (their listeners never fire — the process is * exiting, Node's behavior; the OS reparents the children). */ - scr_children_teardown(); + if (scr_loop_children != NULL) scr_loop_children->teardown(); scr_fibers_abandoned = scr_fibers_live; scr_note_abandoned_fibers(scr_fibers_abandoned); return rejection_failed; diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 87ead5755..fe86b224a 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -33,6 +33,7 @@ static void scr_ipc_deliver(void); static void scr_ipc_settle(ScrIpc *ipc); static void scr_child_fire_spawn(ScrChild *c); static void scr_child_drop_spawn_listeners(ScrChild *c); +static void scr_child_loop_install(void); static ScrArr *scr_fork_argv(double target, uintptr_t read_handle, uintptr_t write_handle, ScrArr *args) { @@ -2372,6 +2373,7 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, scr_children_reffed_n++; c->next = scr_children; scr_children = scr_child_retain(c); + scr_child_loop_install(); return c; } @@ -5081,6 +5083,7 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, scr_children_reffed_n++; c->next = scr_children; scr_children = scr_child_retain(c); + scr_child_loop_install(); return c; } @@ -6087,6 +6090,7 @@ static ScrIpc *scr_ipc_new(ScrChildStream *reader, ScrChildWriter *writer) { ipc->registered = true; ipc->next = scr_ipcs; scr_ipcs = scr_ipc_retain(ipc); /* service-registry reference */ + scr_child_loop_install(); ipc->writer_error_cb = scr_ipc_closure(ipc, (void *)&scr_ipc_writer_error); scr_child_writer_on_error(writer, ipc->writer_error_cb, &scr_ipc_writer_error, false); @@ -6755,6 +6759,26 @@ void scr_child_dyn_install(void) { scr_dyn_handle_install(SCR_DYNH_CHILD, &scr_child_dyn_ops); } +static const ScrLoopChildren scr_child_loop_hooks = { + &scr_children_pending, + &scr_children_ready, + &scr_children_reffed_pending, + &scr_children_failed_pending, + &scr_children_poll, + &scr_children_wait, + &scr_children_wake_fd, + &scr_children_teardown, +}; + +/* Points the loop at the child unit (once per thread, like the + * registries) when the first child or IPC channel is created. */ +static void scr_child_loop_install(void) { + static SCR_TL bool installed = false; + if (installed) return; + installed = true; + scr_loop_set_children(&scr_child_loop_hooks); +} + void scr_child_on_dyn(ScrChild *child, ScrStr *event, ScrDyn *listener) { scr_child_dyn_install(); ScrDyn *self = scr_dyn_new_handle(child, SCR_DYNH_CHILD); diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index 1187985c8..48e22bb06 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -292,5 +292,19 @@ bool scr_children_ready(void); bool scr_children_failed_pending(void); void scr_children_poll(void); bool scr_children_wait(double max_wait_ms); +/* The loop reaches the functions above through this table. The child + * unit installs it with the first spawned child or IPC channel, so + * programs that never create either do not link the unit. */ +typedef struct ScrLoopChildren { + bool (*pending)(void); + bool (*ready)(void); + bool (*reffed_pending)(void); + bool (*failed_pending)(void); + void (*poll)(void); + bool (*wait)(double max_wait_ms); + int (*wake_fd)(void); + void (*teardown)(void); +} ScrLoopChildren; +void scr_loop_set_children(const ScrLoopChildren *hooks); #endif /* SCR_CHILD_H */ diff --git a/tests/harness/runtime-tree-shaking.test.ts b/tests/harness/runtime-tree-shaking.test.ts index 7d85a48c3..b33a0d2f9 100644 --- a/tests/harness/runtime-tree-shaking.test.ts +++ b/tests/harness/runtime-tree-shaking.test.ts @@ -172,6 +172,33 @@ nativeToolchainTest( }, ); +nativeToolchainTest( + "the event loop links the child-process unit only for programs that spawn", + async () => { + const timer = await build( + "async-timer", + `await new Promise((resolve) => setTimeout(resolve, 1)); +console.log("timer"); +`, + ); + await expectNodeParity(timer.sourcePath, timer.binaryPath); + const timerSymbols = await symbols(timer.binaryPath); + for (const family of ["scr_children_", "scr_child_", "scr_ipc_"]) { + expect(timerSymbols, `the timer program retains ${family}`).not.toContain(family); + } + + const spawned = await build( + "async-spawn", + `import { spawn } from "node:child_process"; +const child = spawn("/bin/echo", ["child"], { stdio: "ignore" }); +child.on("close", (code) => console.log("close", code)); +`, + ); + await expectNodeParity(spawned.sourcePath, spawned.binaryPath); + expect(await symbols(spawned.binaryPath)).toContain("scr_children_poll"); + }, +); + nativeToolchainTest("fetch response JSON retains the URL and parser runtime", async () => { const server = createServer((_request, response) => { response.setHeader("content-type", "application/json"); From 37bac645aa5ef14e1d7b9e8a86c82be0ef329410 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 08:32:43 -0500 Subject: [PATCH 10/85] Read call arguments through NodeArray.nodes in the readline dispose and getTestContext checks --- packages/compiler/src/frontend/lowering/builtins/readline.ts | 2 +- packages/compiler/src/frontend/lowering/lower-test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/readline.ts b/packages/compiler/src/frontend/lowering/builtins/readline.ts index 0c0da9a40..830e6823d 100644 --- a/packages/compiler/src/frontend/lowering/builtins/readline.ts +++ b/packages/compiler/src/frontend/lowering/builtins/readline.ts @@ -326,7 +326,7 @@ export function lowerReadlineDisposeCall(lowerer: Lowerer, call: ts.CallExpressi return null; } if (!isReadlineTyped(lowerer, access.expression)) return null; - if (call.arguments.length > 0) { + if (call.arguments.nodes.length > 0) { lowerer.noLowering("readline.Interface[Symbol.dispose] with arguments", call); } const recv = lowerer.lowerExprExpecting(access.expression, F64); diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index 5fc333abc..5c6011425 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -685,9 +685,9 @@ export function lowerNodeTestModuleCall( * and code reached only through timer, immediate, nextTick and * queueMicrotask callbacks). */ function lowerGetTestContext(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { - if (expr.arguments.length > 0) { + if (expr.arguments.nodes.length > 0) { lowerer.noLowering( - `getTestContext with ${expr.arguments.length} arguments`, + `getTestContext with ${expr.arguments.nodes.length} arguments`, expr, "the supported form is getTestContext()", ); From ee8011cea5560f76041a100016215e5072a788ad Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:03:34 -0700 Subject: [PATCH 11/85] Split esbuild CommonJS chunks into their own modules Recognize esbuild's __commonJS helper (keyed and arrow forms, the Object.keys spelling of 0.11) and move every chunk body into a virtual sibling CommonJS module, so require_x() becomes a require of that module and the ordinary CommonJS machinery compiles it. Also accept minified helper declarations that share one var statement, counter-suffixed helper names inside bundled bundles, and treat .cjs files as CommonJS in the interop rewrite. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/helper-tokens.test.ts | 8 +- .../compiler/src/frontend/helper-tokens.ts | 15 +- .../compiler/src/frontend/input-tracker.ts | 30 +- .../src/frontend/npm-static-bundled-chunks.ts | 586 ++++++++++++++++++ .../src/frontend/npm-static-rewrite-host.ts | 2 +- .../src/frontend/npm-static-rewrite-syntax.ts | 143 +++-- packages/compiler/src/frontend/npm-static.ts | 25 +- packages/compiler/src/frontend/services.ts | 8 + 8 files changed, 761 insertions(+), 56 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-static-bundled-chunks.ts diff --git a/packages/compiler/src/frontend/helper-tokens.test.ts b/packages/compiler/src/frontend/helper-tokens.test.ts index c6355cef9..522b39545 100644 --- a/packages/compiler/src/frontend/helper-tokens.test.ts +++ b/packages/compiler/src/frontend/helper-tokens.test.ts @@ -27,6 +27,8 @@ test.each([ ['"default"', '"default;s3:foo;"'], ['"default"', '"default\\0"'], ["mod", "mod other"], + ["cb[0]", "cb[1]"], + ["cb[1]", "cb[10]"], ])("retains a semantic difference between %s and %s", (a, b) => { expect(helperTokens(a)).not.toBe(helperTokens(b)); }); @@ -44,8 +46,12 @@ test.each([ "'\\8'", "`template`", "/regexp/", - "0", "0x10", + "07", + "1.5", + "1e3", + "1n", + "1_000", "a / b", "\\u0061", "café", diff --git a/packages/compiler/src/frontend/helper-tokens.ts b/packages/compiler/src/frontend/helper-tokens.ts index d7a968755..908cc0d22 100644 --- a/packages/compiler/src/frontend/helper-tokens.ts +++ b/packages/compiler/src/frontend/helper-tokens.ts @@ -1,8 +1,8 @@ /** Token fingerprints for the fixed bundler helpers we recognize. This is * deliberately not a JavaScript parser: regexes, templates, escaped names, - * numbers, and unknown punctuation refuse the helper. None occur in the - * canonical helpers. Every accepted token is represented in the result; - * comments and whitespace alone may disappear. */ + * numbers other than plain decimal integers, and unknown punctuation refuse + * the helper. None occur in the canonical helpers. Every accepted token is + * represented in the result; comments and whitespace alone may disappear. */ const operators = [ ">>>=", "===", @@ -157,6 +157,15 @@ export function helperTokens(text: string): string | null { result += fingerprint("s", token.value); i = token.end; previous = "string"; + } else if (/[0-9]/.test(ch)) { + // Plain decimal integers only (esbuild's `cb[keys(cb)[0]]`): a + // legacy octal, fraction, exponent, radix prefix, BigInt suffix or + // numeric separator refuses rather than aliasing another value. + const start = i++; + while (i < text.length && /[0-9]/.test(text.charAt(i))) i++; + if (/[A-Za-z0-9_$.\\]/.test(text.charAt(i)) || (ch === "0" && i - start > 1)) return null; + previous = text.slice(start, i); + result += fingerprint("n", previous); } else if (/[A-Za-z_$]/.test(ch)) { const start = i++; while (i < text.length && /[A-Za-z0-9_$]/.test(text.charAt(i))) i++; diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index 304a21cfa..b710988a3 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -164,8 +164,24 @@ export function markFrontendInputsUnstable(): void { (synchronousTracker ?? activeTracker.getStore())?.markUnstable(); } +/** Compiler-synthesized module sources (a bundle's CommonJS chunks split + * into sibling modules, npm-static-bundled-chunks.ts). They answer like + * regular files but are never recorded: each derives from the content of + * a recorded bundle read, which already keys every cache that sees it. */ +const virtualSources = new Map(); + +export function setVirtualSource(path: string, text: string): void { + virtualSources.set(resolve(path), text); +} + +export function clearVirtualSources(): void { + virtualSources.clear(); +} + export function trackedReadFile(path: string): string | null { path = resolve(path); + const virtual = virtualSources.get(path); + if (virtual !== undefined) return virtual; try { const text = readFileSync(path, "utf8"); record({ op: "file", path, digest: frontendSourceDigest(text) }); @@ -178,6 +194,7 @@ export function trackedReadFile(path: string): string | null { export function trackedFileExists(path: string): boolean { path = resolve(path); + if (virtualSources.has(path)) return true; const kind = pathKind(path); record({ op: "kind", path, kind }); return kind === "file"; @@ -185,6 +202,7 @@ export function trackedFileExists(path: string): boolean { export function trackedDirectoryExists(path: string): boolean { path = resolve(path); + if (virtualSources.has(path)) return false; const kind = pathKind(path); record({ op: "kind", path, kind }); return kind === "directory"; @@ -192,6 +210,7 @@ export function trackedDirectoryExists(path: string): boolean { export function trackedExists(path: string): boolean { path = resolve(path); + if (virtualSources.has(path)) return true; const kind = pathKind(path); record({ op: "kind", path, kind }); return kind !== "missing"; @@ -199,6 +218,7 @@ export function trackedExists(path: string): boolean { export function trackedRealpath(path: string): string | null { path = resolve(path); + if (virtualSources.has(path)) return path; try { const target = systemRealpath(path); record({ op: "realpath", path, target }); @@ -223,9 +243,13 @@ export function trackedAccessibleEntries( } files.sort(); directories.sort(); - const answer = { files, directories }; - record({ op: "entries", path, files, directories }); - return answer; + record({ op: "entries", path, files: [...files], directories }); + for (const virtual of virtualSources.keys()) { + if (dirname(virtual) === path && !files.includes(basename(virtual))) + files.push(basename(virtual)); + } + files.sort(); + return { files, directories }; } catch { // Enumeration can fail even while the path remains a directory. Preserve // the failed operation itself so a permission/ACL repair invalidates the diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts new file mode 100644 index 000000000..e1256e59f --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -0,0 +1,586 @@ +/* Bundled CommonJS chunks: esbuild wraps every CommonJS module it bundles + * in a lazily evaluated factory, + * + * var require_x = __commonJS({ "src/x.js"(exports, module) { BODY } }); + * + * and each `require_x()` runs BODY once (later calls answer the cached + * module.exports, a call during BODY answers the partial one). That is + * exactly a CommonJS module that the bundle requires at the call's + * position, so each recognized chunk becomes its own module: a virtual + * sibling file `..cjs` holding BODY, its module + * parameters respelled as the CommonJS globals, and every `require_x()` + * call becomes `require("./..cjs")`. The program then + * compiles chunks with the ordinary CommonJS machinery: evaluation at the + * require's position, the run-once module cache, export tables, and the + * __toESM interop erasure. + * + * Only shapes whose meaning is verified move: the helper must be one of + * the recognized __commonJS spellings, every reference to a factory + * binding must be a direct call, and BODY must not observe the + * difference between a factory and a module (its `this`, `arguments`, + * `__filename`, the module object beyond `module.exports`, a top-level + * return, or a binding of the bundle other than the pure helpers copied + * beside it). Anything else answers a degrade reason. */ +import * as ts from "./ts7/syntax.js"; +import { helperTokens } from "./helper-tokens.js"; + +export interface BundledChunkSplit { + /** The bundle with chunk declarations blanked and calls respelled. */ + text: string; + /** Virtual sibling modules, in declaration order. */ + chunks: { path: string; source: string }[]; +} + +interface Edit { + start: number; + end: number; + text: string; +} + +const CHUNK_SHAPE_DEGRADE = + "its shipped JS bundles CommonJS modules through the __commonJS helper in a shape the " + + "recognizer cannot verify"; + +/** The helper a counter-suffixed bundler name spells (`__commonJS2`). */ +function helperBase(name: string): string { + return /^__[A-Za-z]+\d+$/.test(name) ? name.replace(/\d+$/, "") : name; +} + +function unparen(e: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** True for an identifier occurrence that reads or writes a binding (as + * opposed to a property name, a label, or a declaration's own name). + * Shorthand property values count: `{ x }` reads x. */ +function isReference(id: ts.Identifier): boolean { + const p = id.parent; + if (p === undefined) return false; + if (ts.isPropertyAccessExpression(p)) return p.expression === id; + if (ts.isPropertyAssignment(p)) return p.initializer === id; + if ( + ts.isMethodDeclaration(p) || + ts.isPropertyDeclaration(p) || + ts.isGetAccessorDeclaration(p) || + ts.isSetAccessorDeclaration(p) + ) + return p.name !== id; + if (ts.isBindingElement(p)) return p.initializer === id; + if (ts.isVariableDeclaration(p) || ts.isParameter(p)) return p.initializer === id; + if ( + ts.isFunctionDeclaration(p) || + ts.isFunctionExpression(p) || + ts.isClassDeclaration(p) || + ts.isClassExpression(p) + ) + return p.name !== id; + if (ts.isLabeledStatement(p)) return p.label !== id; + if (ts.isBreakStatement(p) || ts.isContinueStatement(p)) return false; + if (ts.isMetaProperty(p)) return false; + return true; +} + +/** Names a subtree declares anywhere inside it: variables (destructuring + * included), parameters, function and class names, catch bindings. */ +function declaredNames(root: ts.Node): Set { + const names = new Set(); + // Binding patterns nest BindingElements, which the walk visits too. + ts.walkPreorder(root, (n) => { + if (n.kind >= ts.SyntaxKind.FirstJSDocNode && n.kind <= ts.SyntaxKind.LastJSDocNode) + return "skip"; + if (ts.isVariableDeclaration(n) || ts.isParameter(n) || ts.isBindingElement(n)) { + const name = n.name; + if (name !== undefined && ts.isIdentifier(name)) names.add(name.text); + } else if ( + (ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isClassDeclaration(n) || + ts.isClassExpression(n)) && + n.name !== undefined + ) + names.add(n.name.text); + return undefined; + }); + return names; +} + +/** Every binding reference inside a subtree. */ +function references(root: ts.Node): ts.Identifier[] { + const out: ts.Identifier[] = []; + ts.walkPreorder(root, (n) => { + if (n.kind >= ts.SyntaxKind.FirstJSDocNode && n.kind <= ts.SyntaxKind.LastJSDocNode) + return "skip"; + if (ts.isIdentifier(n) && isReference(n)) out.push(n); + return undefined; + }); + return out; +} + +/** True when the reference is written (assignment target, update operand, + * loop target, delete operand). */ +function isWritten(id: ts.Identifier): boolean { + let child: ts.Node = id; + for (let p = child.parent; p !== undefined; child = p, p = p.parent) { + if (ts.isBinaryExpression(p)) { + return ( + p.left === child && + p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + p.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ); + } + if (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) { + return ( + p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken + ); + } + if (ts.isForInStatement(p) || ts.isForOfStatement(p)) return p.initializer === child; + if (ts.isDeleteExpression(p)) return true; + if ( + !( + ts.isParenthesizedExpression(p) || + ts.isArrayLiteralExpression(p) || + ts.isObjectLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isPropertyAssignment(p) || + ts.isSpreadElement(p) + ) + ) + return false; + } + return false; +} + +/** The recognized __commonJS helper forms, as token templates over the + * helper's own parameter names (CB, MOD) and the own-key reader (KEYS: + * esbuild's `__getOwnPropNames` alias, or `Object.keys` in esbuild 0.11): + * keyed: `(CB, MOD) => function [name]() { return ; }` — the + * factory is the only method of an object literal; + * arrow: `(CB, MOD) => () => ()` — the factory itself. */ +const KEYED_RETURN = "MOD || (0, CB[KEYS(CB)[0]])((MOD = { exports: {} }).exports, MOD), MOD.exports"; +const ARROW_BODY = "(MOD || CB((MOD = { exports: {} }).exports, MOD), MOD.exports)"; + +function template(text: string, cb: string, mod: string, keys: string): string | null { + return helperTokens( + text + .replace(/\bCB\b/g, cb) + .replace(/\bMOD\b/g, mod) + .replace(/\bKEYS\b/g, keys), + ); +} + +type HelperForm = "keyed" | "arrow"; + +/** The helper's form, or null when its initializer is not recognized. + * `ownNames` answers whether an identifier names the verified + * Object.getOwnPropertyNames alias. */ +function helperForm( + init: ts.Expression, + sf: ts.SourceFile, + ownNames: (name: string) => boolean, +): HelperForm | null { + const fn = unparen(init); + if (!ts.isArrowFunction(fn) || fn.parameters.nodes.length !== 2) return null; + const params = fn.parameters.nodes.map((p) => + ts.isIdentifier(p.name) && p.initializer === undefined && p.dotDotDotToken === undefined + ? p.name.text + : null, + ); + const [cb, mod] = params; + if (cb === null || mod === null || cb === undefined || mod === undefined || cb === mod) + return null; + if (ts.isBlock(fn.body)) return null; + const body = unparen(fn.body); + if (ts.isFunctionExpression(body)) { + if (body.parameters.nodes.length !== 0 || body.asteriskToken || body.modifiers?.nodes.length) + return null; + const stmts = body.body.statements.nodes; + const ret = stmts[0]; + if (stmts.length !== 1 || ret === undefined || !ts.isReturnStatement(ret) || !ret.expression) + return null; + const actual = helperTokens(ret.expression.getText(sf)); + if (actual === null) return null; + // The own-key reader: esbuild's alias or the 0.11 spelling. + const keysRef = /\[\s*([A-Za-z_$][\w$]*(?:\.keys)?)\s*\(/.exec(ret.expression.getText(sf)); + const keys = keysRef?.[1]; + if (keys === undefined || (keys !== "Object.keys" && !ownNames(keys))) return null; + return actual === template(KEYED_RETURN, cb, mod, keys) ? "keyed" : null; + } + if (ts.isArrowFunction(body) && body.parameters.nodes.length === 0 && !ts.isBlock(body.body)) { + const actual = helperTokens(body.body.getText(sf)); + return actual !== null && actual === template(ARROW_BODY, cb, mod, "") ? "arrow" : null; + } + return null; +} + +/** A top-level declaration the virtual chunk can copy verbatim: a pure + * helper whose evaluation runs nothing (a function, or a member read of + * Object such as `Object.create` / `Object.prototype.hasOwnProperty`). */ +function copyableInitializer(init: ts.Expression | undefined): boolean { + if (init === undefined) return false; + const e = unparen(init); + if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return true; + let walk: ts.Expression = e; + let depth = 0; + while (ts.isPropertyAccessExpression(walk) && ts.isIdentifier(walk.name)) { + walk = walk.expression; + depth++; + } + return depth > 0 && ts.isIdentifier(walk) && walk.text === "Object"; +} + +interface TopLevel { + stmt: ts.Statement; + /** The declarator (or function declaration) itself. */ + node: ts.VariableDeclaration | ts.FunctionDeclaration; +} + +/** True when the file opens with a "use strict" directive prologue. */ +function strictPrologue(stmts: readonly ts.Statement[]): boolean { + for (const s of stmts) { + if (!ts.isExpressionStatement(s) || !ts.isStringLiteral(s.expression)) return false; + if (s.expression.text === "use strict") return true; + } + return false; +} + +/** Splits a CommonJS bundle's __commonJS chunks into virtual modules (see + * the header). Null: no chunks (or not a CommonJS bundle) — serve the file + * unchanged. */ +export function splitBundledChunks( + sf: ts.SourceFile, + filePath: string, +): BundledChunkSplit | { degrade: string } | null { + const source = sf.text; + if (!source.includes("__commonJS")) return null; + // ES-module bundles have no require: their chunks keep the existing + // treatment (the Bun factory recognizer, or the ESM interop refusal). + if (!/\.cjs$/.test(filePath) && ts.isExternalModule(sf)) return null; + + const top = new Map(); + const duplicate = new Set(); + for (const stmt of sf.statements.nodes) { + const add = (name: string, node: TopLevel["node"]): void => { + if (top.has(name)) duplicate.add(name); + else top.set(name, { stmt, node }); + }; + if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) add(stmt.name.text, stmt); + else if (ts.isVariableStatement(stmt)) { + for (const d of stmt.declarationList.declarations.nodes) { + if (ts.isIdentifier(d.name)) add(d.name.text, d); + } + } + } + const helperNames = [...top.keys()].filter((n) => helperBase(n) === "__commonJS"); + if (helperNames.length === 0) return null; + + // Every name a recognizer relies on is written nowhere and declared + // once at the top level; `Object` itself is never rebound. + const allRefs = references(sf); + const written = new Set(); + for (const r of allRefs) if (isWritten(r)) written.add(r.text); + const declaredAnywhere = declaredNames(sf); + if (declaredAnywhere.has("Object") || written.has("Object")) return { degrade: CHUNK_SHAPE_DEGRADE }; + const stable = (name: string): boolean => !duplicate.has(name) && !written.has(name); + const ownNames = (name: string): boolean => { + const t = top.get(name); + if (t === undefined || !stable(name) || !ts.isVariableDeclaration(t.node)) return false; + const init = t.node.initializer; + return ( + init !== undefined && + helperTokens(init.getText(sf)) === helperTokens("Object.getOwnPropertyNames") + ); + }; + const forms = new Map(); + for (const name of helperNames) { + const t = top.get(name)!; + const init = ts.isVariableDeclaration(t.node) ? t.node.initializer : undefined; + const form = init !== undefined && stable(name) ? helperForm(init, sf, ownNames) : null; + if (form === null) return { degrade: CHUNK_SHAPE_DEGRADE }; + forms.set(name, form); + } + + // The chunk declarations: `var NAME = HELPER(factory);`, one declarator. + interface Chunk { + name: string; + stmt: ts.VariableStatement; + params: string[]; + body: ts.Block; + path: string; + spec: string; + } + const chunks = new Map(); + const base = filePath.replace(/^.*[\\/]/, ""); + for (const [name, t] of top) { + if (!ts.isVariableDeclaration(t.node) || t.node.initializer === undefined) continue; + const call = unparen(t.node.initializer); + if (!ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) continue; + const form = forms.get(call.expression.text); + if (form === undefined) continue; + const stmt = t.stmt; + if ( + !ts.isVariableStatement(stmt) || + stmt.declarationList.declarations.nodes.length !== 1 || + call.arguments.nodes.length !== 1 || + !stable(name) + ) + return { degrade: CHUNK_SHAPE_DEGRADE }; + let factory: ts.Node = call.arguments.nodes[0]!; + if (form === "keyed") { + if (!ts.isObjectLiteralExpression(factory) || factory.properties.nodes.length !== 1) + return { degrade: CHUNK_SHAPE_DEGRADE }; + factory = factory.properties.nodes[0]!; + if (!ts.isMethodDeclaration(factory) || ts.isComputedPropertyName(factory.name)) + return { degrade: CHUNK_SHAPE_DEGRADE }; + } else { + factory = unparen(factory as ts.Expression); + if (!ts.isArrowFunction(factory) && !ts.isFunctionExpression(factory)) + return { degrade: CHUNK_SHAPE_DEGRADE }; + } + const fn = factory as ts.MethodDeclaration | ts.ArrowFunction | ts.FunctionExpression; + if ( + fn.body === undefined || + !ts.isBlock(fn.body) || + fn.asteriskToken !== undefined || + (fn.modifiers?.nodes.length ?? 0) > 0 || + fn.parameters.nodes.length > 2 + ) + return { degrade: CHUNK_SHAPE_DEGRADE }; + const params: string[] = []; + for (const p of fn.parameters.nodes) { + if (!ts.isIdentifier(p.name) || p.initializer !== undefined || p.dotDotDotToken) + return { degrade: CHUNK_SHAPE_DEGRADE }; + params.push(p.name.text); + } + const file = `${base}.${name}.cjs`; + chunks.set(name, { + name, + stmt, + params, + body: fn.body, + path: filePath.slice(0, filePath.length - base.length) + file, + spec: `./${file}`, + }); + } + if (chunks.size === 0) return null; + + // Every factory reference is a direct zero-argument call. Calls inside a + // chunk body belong to that chunk's text, the rest to the bundle's. + const callEdits: Edit[] = []; + for (const r of allRefs) { + const chunk = chunks.get(r.text); + if (chunk === undefined) continue; + const call = r.parent; + if ( + call === undefined || + !ts.isCallExpression(call) || + call.expression !== r || + call.arguments.nodes.length !== 0 || + call.questionDotToken !== undefined + ) + return { + degrade: `its bundled CommonJS module factory '${r.text}' is used other than as a direct call`, + }; + callEdits.push({ + start: call.getStart(sf), + end: call.getEnd(), + text: `require(${JSON.stringify(chunk.spec)})`, + }); + } + for (const name of chunks.keys()) { + if (declaredAnywhere.has(name) && countDeclarations(sf, name) > 1) + return { degrade: CHUNK_SHAPE_DEGRADE }; + } + + const bundleStrict = strictPrologue(sf.statements.nodes); + const out: BundledChunkSplit["chunks"] = []; + const bundleEdits: Edit[] = [...callEdits]; + for (const chunk of chunks.values()) { + const result = chunkSource(sf, chunk, chunks, top, stable, callEdits, bundleStrict); + if (typeof result !== "string") return result; + out.push({ path: chunk.path, source: result }); + bundleEdits.push({ start: chunk.stmt.getStart(sf), end: chunk.stmt.getEnd(), text: "" }); + } + return { text: applyEdits(source, 0, source.length, bundleEdits), chunks: out }; +} + +function countDeclarations(sf: ts.SourceFile, name: string): number { + let n = 0; + ts.walkPreorder(sf, (node) => { + if ( + (ts.isVariableDeclaration(node) || + ts.isParameter(node) || + ts.isBindingElement(node) || + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node)) && + node.name !== undefined && + ts.isIdentifier(node.name) && + node.name.text === name + ) + n++; + return undefined; + }); + return n; +} + +/** Applies non-overlapping edits inside [from, to): replaced spans keep + * their newlines (blanked text stays line-aligned); edits nested in a + * blanked span vanish with it. */ +function applyEdits(source: string, from: number, to: number, edits: Edit[]): string { + const inside = edits + .filter((e) => e.start >= from && e.end <= to) + .sort((a, b) => a.start - b.start || b.end - a.end); + let text = ""; + let at = from; + for (const e of inside) { + if (e.start < at) continue; // nested in an earlier edit + text += source.slice(at, e.start); + const replaced = source.slice(e.start, e.end); + // A respelled span keeps its line breaks after the replacement. + text += + e.text === "" + ? replaced.replace(/[^\r\n]/g, " ") + : e.text + replaced.replace(/[^\n]/g, ""); + at = e.end; + } + return text + source.slice(at, to); +} + +/** The virtual module text for one chunk, or a degrade reason. */ +function chunkSource( + sf: ts.SourceFile, + chunk: { + name: string; + params: string[]; + body: ts.Block; + }, + chunks: ReadonlyMap, + top: ReadonlyMap, + stable: (name: string) => boolean, + callEdits: readonly Edit[], + bundleStrict: boolean, +): string | { degrade: string } { + const why = (reason: string): { degrade: string } => ({ + degrade: `its bundled CommonJS module '${chunk.name}' ${reason}`, + }); + const [exportsParam, moduleParam] = chunk.params; + const body = chunk.body; + const local = declaredNames(body); + for (const p of chunk.params) { + if (local.has(p) || (p !== "exports" && local.has("exports")) || (p !== "module" && local.has("module"))) + return why( + "rebinds its module parameters inside its body (an identifier-minified bundle the recognizer cannot respell)", + ); + } + + /* The factory's own frame must stay unobservable: its receiver, its + * arguments object, and a top-level return have no module equivalent. */ + let frameUse: string | null = null; + /** The innermost frame boundary between a node and the chunk body. */ + const frameOf = (n: ts.Node): "factory" | "arrow" | "function" => { + let arrow = false; + for (let p = n.parent; p !== undefined && p !== body; p = p.parent) { + if ( + ts.isFunctionDeclaration(p) || + ts.isFunctionExpression(p) || + ts.isMethodDeclaration(p) || + ts.isGetAccessorDeclaration(p) || + ts.isSetAccessorDeclaration(p) || + ts.isConstructorDeclaration(p) || + ts.isClassDeclaration(p) || + ts.isClassExpression(p) + ) + return "function"; + if (ts.isArrowFunction(p)) arrow = true; + } + return arrow ? "arrow" : "factory"; + }; + ts.walkPreorder(body, (n) => { + if (frameUse !== null) return "stop"; + if (n.kind >= ts.SyntaxKind.FirstJSDocNode && n.kind <= ts.SyntaxKind.LastJSDocNode) + return "skip"; + let use: string | null = null; + if (n.kind === ts.SyntaxKind.ThisKeyword) use = "reads its factory's receiver (`this`)"; + else if (n.kind === ts.SyntaxKind.SuperKeyword) use = "reads `super` at its top level"; + else if (ts.isIdentifier(n) && n.text === "arguments" && isReference(n)) + use = "reads its factory's arguments object"; + else if (ts.isMetaProperty(n)) use = "reads a meta property at its top level"; + else if (ts.isReturnStatement(n)) { + if (frameOf(n) === "factory") frameUse = "returns from its top level"; + return undefined; + } + if (use !== null && frameOf(n) !== "function") frameUse = use; + return undefined; + }); + if (frameUse !== null) return why(frameUse); + + const edits: Edit[] = callEdits.filter((e) => e.start >= body.pos && e.end <= body.end); + const copied = new Set(); + const copyQueue: string[] = []; + for (const r of references(body)) { + const name = r.text; + if (chunks.has(name)) continue; // a call edit already respells it + if (name === exportsParam || name === moduleParam) { + if (name === moduleParam) { + const p = r.parent; + if (!ts.isPropertyAccessExpression(p) || p.expression !== r || p.name.text !== "exports") + return why("uses its module object beyond module.exports"); + } + const global = name === exportsParam ? "exports" : "module"; + if (name !== global) { + const p = r.parent; + edits.push( + ts.isShorthandPropertyAssignment(p) + ? { start: r.getStart(sf), end: r.getEnd(), text: `${name}: ${global}` } + : { start: r.getStart(sf), end: r.getEnd(), text: global }, + ); + } + continue; + } + if (local.has(name)) continue; + if (name === "exports" || name === "module") + return why(`reads the bundle's own '${name}' binding`); + if (name === "__filename") return why("reads __filename, which names the bundle file"); + if (top.has(name)) { + if (!copied.has(name)) { + copied.add(name); + copyQueue.push(name); + } + } + } + + // The pure helpers BODY reads, closed over their own top-level reads. + const header: string[] = []; + for (let i = 0; i < copyQueue.length; i++) { + const name = copyQueue[i]!; + const t = top.get(name)!; + const node = t.node; + const pure = + stable(name) && + (ts.isFunctionDeclaration(node) || + (ts.isVariableDeclaration(node) && copyableInitializer(node.initializer))); + if (!pure || chunks.has(name)) + return why(`reads the bundle's top-level binding '${name}', which it cannot carry along`); + const own = declaredNames(node); + for (const r of references(node)) { + if (own.has(r.text) || !top.has(r.text) || copied.has(r.text)) continue; + copied.add(r.text); + copyQueue.push(r.text); + } + } + // Declaration order of the bundle: a helper reads only earlier ones. + const ordered = [...copied].sort( + (a, b) => top.get(a)!.node.getStart(sf) - top.get(b)!.node.getStart(sf), + ); + for (const name of ordered) { + const node = top.get(name)!.node; + header.push(ts.isFunctionDeclaration(node) ? node.getText(sf) : `var ${node.getText(sf)};`); + } + + const bodyStrict = strictPrologue(body.statements.nodes); + const text = applyEdits(sf.text, body.getStart(sf) + 1, body.getEnd() - 1, edits); + const prologue = bundleStrict || bodyStrict ? '"use strict";\n' : ""; + return prologue + (header.length > 0 ? header.join("\n") + "\n" : "") + text + "\n"; +} diff --git a/packages/compiler/src/frontend/npm-static-rewrite-host.ts b/packages/compiler/src/frontend/npm-static-rewrite-host.ts index ee36f58d1..5d9675b4a 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-host.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-host.ts @@ -134,7 +134,7 @@ function requireTargetEsModuleStamped( } catch { /* unlexable — keep probing */ } - if (src.includes("__toCommonJS(")) return true; + if (/__toCommonJS\d*\(/.test(src)) return true; const fwd = /module\.exports\s*=\s*require\(\s*["']([^"']+)["']\s*\)/.exec(src); if (fwd !== null) return requireTargetEsModuleStamped(parser, file, fwd[1]!, depth + 1); return false; diff --git a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts index 7938471f3..c7e788c1b 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts @@ -24,16 +24,31 @@ export function isBundlerCjsCandidate(source: string): boolean { ); } +/** The helper a bundler-emitted name spells. esbuild suffixes a helper + * that a bundled module declares again with a counter (a bundled esbuild + * dist inside another bundle carries `__export2`, `__toCommonJS2`, …); the + * recognizers below verify each call's shape, so the counter carries no + * meaning beyond the name collision it avoided. */ +export function bundlerHelperBase(name: string): string { + return /^__[A-Za-z]+\d+$/.test(name) ? name.replace(/\d+$/, "") : name; +} + /** The last NAME of a callee: bare identifier or any member chain's final - * name (`tslib_1.__exportStar` → "__exportStar"). */ + * name (`tslib_1.__exportStar` → "__exportStar"), helper counters dropped. */ function calleeNameOf(call: ts.CallExpression): string | null { const callee = call.expression; - if (ts.isIdentifier(callee)) return callee.text; + if (ts.isIdentifier(callee)) return bundlerHelperBase(callee.text); if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) - return callee.name.text; + return bundlerHelperBase(callee.name.text); return null; } +/** True for an identifier spelling the named helper (counter-suffixed + * spellings included). */ +function isHelperIdent(node: ts.Node, base: string): node is ts.Identifier { + return ts.isIdentifier(node) && bundlerHelperBase(node.text) === base; +} + /** An entry the canonical table can carry. */ interface EntrySpec { /** The export name (may need string-key spelling). */ @@ -194,13 +209,8 @@ interface ToEsmPlan { * __copyProps(…, mod) call copying onto a conditional that tests * isNodeMode/__esModule and defines "default" with value mod. Comments and * formatting are free; the SHAPE is the contract. */ -function recognizedToEsmDecl(stmt: ts.Statement): boolean { - if (!ts.isVariableStatement(stmt)) return false; - const decls = stmt.declarationList.declarations.nodes; - if (decls.length !== 1) return false; - const d = decls[0]!; - if (!ts.isIdentifier(d.name) || d.name.text !== "__toESM" || d.initializer === undefined) - return false; +function recognizedToEsmDecl(d: ts.VariableDeclaration): boolean { + if (!isHelperIdent(d.name, "__toESM") || d.initializer === undefined) return false; let init: ts.Expression = d.initializer; while (ts.isParenthesizedExpression(init)) init = init.expression; if (!ts.isArrowFunction(init)) return false; @@ -218,12 +228,7 @@ function recognizedToEsmDecl(stmt: ts.Statement): boolean { expr = expr.right; } while (ts.isParenthesizedExpression(expr)) expr = expr.expression; - if ( - !ts.isCallExpression(expr) || - !ts.isIdentifier(expr.expression) || - expr.expression.text !== "__copyProps" - ) - return false; + if (!ts.isCallExpression(expr) || !isHelperIdent(expr.expression, "__copyProps")) return false; if (expr.arguments.nodes.length < 2) return false; const from = expr.arguments.nodes[1]!; if (!ts.isIdentifier(from) || from.text !== mod) return false; @@ -234,12 +239,7 @@ function recognizedToEsmDecl(stmt: ts.Statement): boolean { if (!condText.includes(isNodeMode) || !condText.includes("__esModule")) return false; let stamp: ts.Expression = to.whenTrue; while (ts.isParenthesizedExpression(stamp)) stamp = stamp.expression; - if ( - !ts.isCallExpression(stamp) || - !ts.isIdentifier(stamp.expression) || - stamp.expression.text !== "__defProp" - ) - return false; + if (!ts.isCallExpression(stamp) || !isHelperIdent(stamp.expression, "__defProp")) return false; if (stamp.arguments.nodes.length !== 3) return false; const nameArg = stamp.arguments.nodes[1]!; if (!ts.isStringLiteral(nameArg) || nameArg.text !== "default") return false; @@ -278,19 +278,23 @@ function planToEsmInterop(sf: ts.SourceFile, filePath: string, host: CjsRewriteH degrade: reason, }); - const declStmts = sf.statements.nodes.filter( - (s) => - ts.isVariableStatement(s) && - s.declarationList.declarations.nodes.some( - (d) => ts.isIdentifier(d.name) && d.name.text === "__toESM", - ), - ); + // Top-level declarators of the helper, any statement shape (minified + // dists share one `var` among several helpers), by spelling. + const decls = new Map(); + for (const s of sf.statements.nodes) { + if (!ts.isVariableStatement(s)) continue; + for (const d of s.declarationList.declarations.nodes) { + if (!isHelperIdent(d.name, "__toESM")) continue; + const list = decls.get(d.name.text); + if (list === undefined) decls.set(d.name.text, [d]); + else list.push(d); + } + } const refs: ts.Identifier[] = []; const calls: ts.CallExpression[] = []; const collect = (n: ts.Node): void => { - if (ts.isIdentifier(n) && n.text === "__toESM") refs.push(n); - if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "__toESM") - calls.push(n); + if (isHelperIdent(n, "__toESM")) refs.push(n); + if (ts.isCallExpression(n) && isHelperIdent(n.expression, "__toESM")) calls.push(n); }; ts.walkPreorder(sf, collect); if (calls.length === 0 && refs.length === 0) return plan; @@ -298,15 +302,16 @@ function planToEsmInterop(sf: ts.SourceFile, filePath: string, host: CjsRewriteH // a helper that escapes as a VALUE is outside the recognized shape. const callees = new Set(calls.map((c) => c.expression)); const declNames = new Set(); - for (const s of declStmts) { - for (const d of (s as ts.VariableStatement).declarationList.declarations.nodes) { - if (ts.isIdentifier(d.name) && d.name.text === "__toESM") declNames.add(d.name); - } - } + for (const list of decls.values()) for (const d of list) declNames.add(d.name); if (refs.some((r) => !callees.has(r) && !declNames.has(r))) return fail(TO_ESM_ESCAPE_DEGRADE); if (calls.length === 0) return plan; // declared but never called — dead helper - if (declStmts.length !== 1 || !recognizedToEsmDecl(declStmts[0]!)) - return fail(TO_ESM_SHAPE_DEGRADE); + // Every spelling a call uses must be declared exactly once, in the + // recognized shape. + for (const call of calls) { + const list = decls.get((call.expression as ts.Identifier).text); + if (list === undefined || list.length !== 1 || !recognizedToEsmDecl(list[0]!)) + return fail(TO_ESM_SHAPE_DEGRADE); + } /** Module-scope interop bindings: name → whether `.default` IS the * module (pad the access) rather than a member read of its `default`. */ @@ -467,7 +472,10 @@ export function rewriteBundlerCjsSyntax( // ESM bundles can contain closed CommonJS function factories. Recognize // those before refusing interop that needs a dynamic module namespace. - if (ts.isExternalModule(sf)) { + // TypeScript forces every `.cjs` file to be an external module + // (isFileForcedToBeModuleByFormat), yet Node always loads one as + // CommonJS: the extension, not that flag, decides the format there. + if (!/\.cjs$/.test(filePath) && ts.isExternalModule(sf)) { const bundled = rewriteBundledFunctionImports(source, sf); if (bundled !== null) return bundled; return source.includes("__toESM(") @@ -814,21 +822,35 @@ export function rewriteBundlerCjsSyntax( "__reExport", "__toESM", "__toCommonJS", + "__commonJS", "__exportStar", "__createBinding", "__setModuleDefault", "__importStar", "__importDefault", ]); + /* Helper declarators by name. A minified dist shares one `var` among + * several helpers (`var __getProtoOf=…,__hasOwnProp=…;`): each + * declarator is its own candidate, and the statement pads whole only + * once every declarator in it is dead. */ const helperDecls = new Map(); + const helperStmts: ts.VariableStatement[] = []; for (const stmt of sf.statements.nodes) { if (!ts.isVariableStatement(stmt)) continue; const decls = stmt.declarationList.declarations.nodes; - if (decls.length !== 1) continue; - const d = decls[0]!; - if (ts.isIdentifier(d.name) && HELPER_NAMES.has(d.name.text)) { - helperDecls.set(d.name.text, { start: stmt.getStart(sf), end: stmt.getEnd() }); + let anyHelper = false; + for (const d of decls) { + if (!ts.isIdentifier(d.name) || !HELPER_NAMES.has(bundlerHelperBase(d.name.text))) continue; + if (helperDecls.has(d.name.text)) continue; // redeclared: keep the first, never sweep twice + anyHelper = true; + helperDecls.set( + d.name.text, + decls.length === 1 + ? { start: stmt.getStart(sf), end: stmt.getEnd() } + : { start: d.getStart(sf), end: d.getEnd() }, + ); } + if (anyHelper && decls.length > 1) helperStmts.push(stmt); } // every identifier occurrence of a helper name outside its own // declarator name node, position-recorded for the span test @@ -862,8 +884,39 @@ export function rewriteBundlerCjsSyntax( } } } + // A swept declarator of a shared statement pads with its separating + // comma, and the statement pads whole once every declarator is dead: + // padding the bare declarator span would leave a dangling comma. + const declaratorSpans = new Set<{ start: number; end: number }>(); + const declaratorPads: { start: number; end: number }[] = []; + for (const stmt of helperStmts) { + const decls = stmt.declarationList.declarations.nodes; + const deadAt = decls.map((d) => { + if (!ts.isIdentifier(d.name) || !removed.has(d.name.text)) return false; + const span = helperDecls.get(d.name.text)!; + if (span.start !== d.getStart(sf)) return false; + declaratorSpans.add(span); + return true; + }); + if (deadAt.every((x) => x)) { + declaratorPads.push({ start: stmt.getStart(sf), end: stmt.getEnd() }); + continue; + } + // Dead declarators before the last live one take their trailing + // comma; the dead tail after it pads from that declarator's end. + const lastLive = deadAt.lastIndexOf(false); + for (let i = 0; i < decls.length; i++) { + if (!deadAt[i]) continue; + const d = decls[i]!; + declaratorPads.push( + i < lastLive + ? { start: d.getStart(sf), end: decls[i + 1]!.getStart(sf) } + : { start: decls[lastLive]!.getEnd(), end: d.getEnd() }, + ); + } + } neutralize.length = 0; - neutralize.push(...dead); + neutralize.push(...dead.filter((span) => !declaratorSpans.has(span)), ...declaratorPads); } /* ── emission ─────────────────────────────────────────────────────── */ diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 3c8fa22e9..e3ae7b600 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -13,7 +13,13 @@ import { registerWorkspacePackage, workspacePackageOfPath, } from "./workspace-registry.js"; -import { trackedExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; +import { + clearVirtualSources, + setVirtualSource, + trackedExists, + trackedReadFile, + trackedRealpath, +} from "./input-tracker.js"; let activePackages: ReadonlySet = new Set(); let declarationOverloads: ReadonlyMap = new Map(); @@ -34,6 +40,7 @@ export function setNpmStaticPackages>(packages: T): v declarationProperties = new Map(); offenders.clear(); rewriteCache.clear(); + clearVirtualSources(); untypedPkgCache.clear(); realpathProbed.clear(); } @@ -362,7 +369,18 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow if (hit !== undefined) return hit ?? undefined; let rewritten: string | null = null; try { - const original = trackedReadFile(path); + let original = trackedReadFile(path); + // A bundle's CommonJS chunks move to their own modules first, so + // every later pass (and this shadow, for each chunk) sees plain + // CommonJS module text. + const split = original === null ? null : services.splitCjsChunks(original, path); + if (split !== null && "degrade" in split) { + reportNpmStaticOffender(target.pkg, split.degrade); + original = null; + } else if (split !== null) { + for (const chunk of split.chunks) setVirtualSource(chunk.path, chunk.source); + original = split.text; + } if (original !== null) { const namepaths = services.jsDocNamepaths(path, original); const source = namepaths ?? original; @@ -395,7 +413,8 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow propertyProjected?.text ?? findWidened?.text ?? classFields?.text ?? - namepaths; + namepaths ?? + (split !== null ? original : null); } } } catch { diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index ca4dd2b36..7a3b980eb 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -2,6 +2,7 @@ import { Ts7SourceParser, type Ts7SourceKind } from "./ts7/source-parser.js"; import { Ts7Host, type Ts7ApiFactory, type Ts7HostOptions } from "./ts7/program-host.js"; import { rewriteBundlerCjsWithParser } from "./npm-static-rewrite-host.js"; import { isBundlerCjsCandidate } from "./npm-static-rewrite-syntax.js"; +import { splitBundledChunks, type BundledChunkSplit } from "./npm-static-bundled-chunks.js"; import * as syntax from "./npm-static-declaration-syntax.js"; import type { SourceFile } from "./ts7/ast-types.js"; @@ -60,6 +61,13 @@ export class FrontendServices { if (!isBundlerCjsCandidate(source)) return null; return rewriteBundlerCjsWithParser(this.sourceParser(), source, path); } + /** A CommonJS bundle's __commonJS chunks as their own modules (null: no + * chunks; a degrade reason: chunks the recognizer cannot move). */ + splitCjsChunks(source: string, path: string): BundledChunkSplit | { degrade: string } | null { + this.ensureOpen(); + if (!source.includes("__commonJS")) return null; + return splitBundledChunks(this.parse(path, source, "js"), path); + } nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null { return syntax.applyNpmStaticNullableClassFields(this.parse(path, source, "js"), source); } From 6b1f1c59910b88c7ccfea9fd72629ddf63ecdb0b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:07:43 -0700 Subject: [PATCH 12/85] Admit require cycles through esbuild's live export tables The interop rewrite certifies a file whose esbuild export object is installed before anything can run another module's code; the CommonJS cycle rule then treats its tail table as settled at that point. A top-level plain read of a cycle member's function-declaration export is exact as well: Node and the compiled read see the same function. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/npm-static-rewrite-host.ts | 2 + .../src/frontend/npm-static-rewrite-syntax.ts | 68 ++++++++++++++++ packages/compiler/src/frontend/npm-static.ts | 11 +++ packages/compiler/src/frontend/program.ts | 78 ++++++++++++++++++- packages/compiler/src/frontend/services.ts | 8 +- 5 files changed, 164 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-rewrite-host.ts b/packages/compiler/src/frontend/npm-static-rewrite-host.ts index 5d9675b4a..d3b2b9ad4 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-host.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-host.ts @@ -163,9 +163,11 @@ export function rewriteBundlerCjsWithParser( parser: Ts7SourceParser, source: string, filePath: string, + liveExportTable?: (filePath: string) => void, ): string | { degrade: string } | null { if (!isBundlerCjsCandidate(source)) return null; return rewriteBundlerCjsSyntax(parser.parse(filePath, source, "js"), filePath, { + liveExportTable, requireTargetEsModuleStamped: (from, spec) => requireTargetEsModuleStamped(parser, from, spec), starTargetNames: (from, spec) => { const target = resolveRelativeCjs(from, spec); diff --git a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts index c7e788c1b..49467782f 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts @@ -12,6 +12,65 @@ import { rewriteBundledFunctionImports } from "./npm-static-bundled-cjs.js"; export interface CjsRewriteHost { requireTargetEsModuleStamped(fromFile: string, specifier: string): boolean; starTargetNames(fromFile: string, specifier: string): Set; + /** Told when the rewritten file's tail export table stands for a live + * getter table the original installed before it could run another + * module's code (liveExportTableFirst): a module of a require cycle that + * captured the export object early observes exactly that table. */ + liveExportTable?: ((filePath: string) => void) | undefined; +} + +/** True when esbuild's export object (`module.exports = __toCommonJS(T)` + * over a getter table `__export(T, {...})`) is installed before anything + * that can run another module's code, and nothing else writes the export + * object. Every statement before the installation is then a declaration, + * a directive, or the getter-table call (whose getters run only when + * read), and variable initializers evaluate no calls outside function + * bodies. The getters read live bindings, so the object a cycle partner + * captures early answers every later read exactly like the final table. */ +function liveExportTableFirst( + sf: ts.SourceFile, + installAt: number, + target: string, + exportTables: readonly { target: string }[], +): boolean { + if (!exportTables.some((t) => t.target === target)) return false; + let callsOutsideFunctions = false; + const noCalls = (root: ts.Node): boolean => { + callsOutsideFunctions = false; + ts.walkPreorder(root, (n) => { + if (callsOutsideFunctions) return "stop"; + if (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) + return "skip"; + if (ts.isCallExpression(n) || ts.isNewExpression(n) || ts.isTaggedTemplateExpression(n)) + callsOutsideFunctions = true; + return undefined; + }); + return !callsOutsideFunctions; + }; + let sawTable = false; + for (const stmt of sf.statements.nodes) { + if (stmt.getStart(sf) >= installAt) return sawTable; + if (ts.isFunctionDeclaration(stmt) || ts.isEmptyStatement(stmt)) continue; + if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; + if (ts.isVariableStatement(stmt)) { + if ( + stmt.declarationList.declarations.nodes.every( + (d) => ts.isIdentifier(d.name) && (d.initializer === undefined || noCalls(d.initializer)), + ) + ) + continue; + return false; + } + if (ts.isExpressionStatement(stmt) && ts.isCallExpression(stmt.expression)) { + const table = exportCallOf(stmt.expression); + if (table !== null && table.target === target) { + sawTable = true; + continue; + } + } + return false; + } + return false; } export function isBundlerCjsCandidate(source: string): boolean { @@ -928,6 +987,15 @@ export function rewriteBundlerCjsSyntax( if (!emitTable) return text; // interop pads only — nothing appends + if ( + esbuildMainTarget !== null && + tablePos !== null && + memberStmts.length === 0 && + definePropEntries.length === 0 && + liveExportTableFirst(sf, tablePos, esbuildMainTarget, exportTables) + ) + host.liveExportTable?.(filePath); + const lines: string[] = [""]; const hasAnyFallback = [...entries.values()].some((s) => s.kind === "any"); if (hasAnyFallback) { diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index e3ae7b600..f377628d6 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -34,12 +34,22 @@ const offenders = new Map(); * the host probes the same file many times, and the rewrite parses. */ const rewriteCache = new Map(); +/** Rewritten files whose tail export table stands for a live getter table + * installed before the file could run another module's code (the rewrite + * certifies it; require-cycle admission consults it). */ +const liveExportFiles = new Set(); + +export function npmStaticLiveExportTable(path: string): boolean { + return liveExportFiles.has(path.split("\\").join("/")); +} + export function setNpmStaticPackages>(packages: T): void { activePackages = new Set(packages); declarationOverloads = new Map(); declarationProperties = new Map(); offenders.clear(); rewriteCache.clear(); + liveExportFiles.clear(); clearVirtualSources(); untypedPkgCache.clear(); realpathProbed.clear(); @@ -403,6 +413,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow classFields?.text ?? source, path, + (live) => liveExportFiles.add(live.split("\\").join("/")), ); if (answer !== null && typeof answer === "object") { reportNpmStaticOffender(target.pkg, answer.degrade); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index bde168c38..d06e966f3 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -68,6 +68,7 @@ import { isNpmStaticPackage, npmStaticActive, npmStaticFsShadow, + npmStaticLiveExportTable, npmStaticPackageOfPath, reportNpmStaticOffender, setNpmStaticDeclarationOverloads, @@ -1438,6 +1439,8 @@ export function requireBindingEarlyUse7( k: number, decl: ts.VariableDeclaration, precedingDecls: readonly ts.VariableDeclaration[], + /** A reference of the binding whose early evaluation is known exact. */ + exactEarlyRead?: (reference: ts.Identifier) => boolean, ): ts.Identifier | null { const checker = program.getTypeChecker(); const bindings = new Set(); @@ -1669,6 +1672,7 @@ export function requireBindingEarlyUse7( const sym = symbolOf(n); if (sym === undefined) return undefined; if (bindings.has(sym)) { + if (exactEarlyRead?.(n) === true) return undefined; hit = n; return "stop"; } @@ -3121,6 +3125,16 @@ function cjsCycleRefusal7( // anything else can change what a module of the cycle observes. let stray: ts.Node | null = null; const settled = new Set(stmts.slice(0, firstEntry).filter(exportStatement)); + // A bundler export table the rewrite certified (npmStaticLiveExportTable) + // stands for the live getter object the original installed before its + // first require: the tail table settles as if it ran there. + const tail = stmts[stmts.length - 1]; + if ( + tail !== undefined && + npmStaticLiveExportTable(m.fileName) && + cjsExportAssignmentOf(tail)?.kind === "table" + ) + settled.add(tail); ts.walkPreorder(m, (n) => { if (stray !== null) return "stop"; if (ts.isStatement(n) && settled.has(n)) return "skip"; @@ -3173,7 +3187,9 @@ function cjsCycleRefusal7( if (req.decl === null) continue; const dep = resolveImport7(program, m, req.spec, "require"); if (dep === null || !members.has(dep)) continue; - const reached = requireBindingEarlyUse7(program, m, stmts.length, req.decl, []); + const reached = requireBindingEarlyUse7(program, m, stmts.length, req.decl, [], (id) => + functionExportRead7(program, id, members), + ); if (reached !== null) { return `the top-level code of ${m.fileName} can use its binding '${reached.text}' of a module of the cycle (${lineOf(reached)}) while the cycle initializes`; } @@ -3183,6 +3199,66 @@ function cjsCycleRefusal7( return null; } +/** True for a plain read `binding.name` (not a call, not a write) whose + * export resolves to a top-level function declaration of a module of the + * cycle that nothing reassigns. Every member settles its exports before + * entering the cycle, so Node reads that same function whenever the read + * runs, and the compiled read resolves to the declaration itself. */ +function functionExportRead7( + program: ts.Program, + id: ts.Identifier, + members: ReadonlySet, +): boolean { + const access = id.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== id) return false; + let use: ts.Node = access; + while (ts.isParenthesizedExpression(use.parent)) use = use.parent; + const parent = use.parent; + if ( + (ts.isCallExpression(parent) && parent.expression === use) || + ts.isDeleteExpression(parent) || + ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && + (parent.operator === ts.SyntaxKind.PlusPlusToken || + parent.operator === ts.SyntaxKind.MinusMinusToken)) || + (ts.isBinaryExpression(parent) && + parent.left === use && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) + ) + return false; + const checker = program.getTypeChecker(); + const resolved = (sym: ts.Symbol | undefined, depth: number): ts.FunctionDeclaration | null => { + if (sym === undefined || depth > 4) return null; + if ((sym.flags & ts.SymbolFlags.Alias) !== 0) sym = checker.getAliasedSymbol(sym); + for (const d of checker.declarationsOf(sym)) { + if (ts.isFunctionDeclaration(d)) return d; + if (ts.isPropertyAssignment(d) && ts.isIdentifier(d.initializer)) + return resolved(checker.getSymbolAtLocation(d.initializer), depth + 1); + if (ts.isShorthandPropertyAssignment(d)) + return resolved(checker.getShorthandAssignmentValueSymbol(d), depth + 1); + } + return null; + }; + const fn = resolved(checker.getSymbolAtLocation(access.name), 0); + if (fn === null || fn.name === undefined || !members.has(fn.getSourceFile())) return false; + if (!ts.isSourceFile(fn.parent)) return false; + const name = fn.name.text; + let reassigned = false; + ts.walkPreorder(fn.getSourceFile(), (n) => { + if (reassigned) return "stop"; + if ( + ts.isBinaryExpression(n) && + ts.isIdentifier(n.left) && + n.left.text === name && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + reassigned = true; + return undefined; + }); + return !reassigned; +} + /* ── exact cyclic initialization ───────────────────────────────────────── * Cycles that fail the declaration-only rule still evaluate exactly like * Node when their shape is representable: the guarded %init calls already diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 7a3b980eb..40a989a0c 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -56,10 +56,14 @@ export class FrontendServices { this.ensureOpen(); return new Ts7Host(this.createApi, { ...options, cwd: options?.cwd ?? this.cwd }); } - rewriteCjs(source: string, path: string): string | { degrade: string } | null { + rewriteCjs( + source: string, + path: string, + liveExportTable?: (path: string) => void, + ): string | { degrade: string } | null { this.ensureOpen(); if (!isBundlerCjsCandidate(source)) return null; - return rewriteBundlerCjsWithParser(this.sourceParser(), source, path); + return rewriteBundlerCjsWithParser(this.sourceParser(), source, path, liveExportTable); } /** A CommonJS bundle's __commonJS chunks as their own modules (null: no * chunks; a degrade reason: chunks the recognizer cannot move). */ From 17647d8bfd8bd0bc086a1773b0db42428072eadb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:17:13 -0700 Subject: [PATCH 13/85] Add esbuild chunk and CommonJS cycle npm fixtures Real esbuild output (0.25 plain and whitespace/syntax minified, 0.17) of one bundle with lazily and eagerly required chunks, a chunk that is itself an esbuild dist, an external through __toESM, a .cjs bundle in a type-module package, and per-file esbuild CommonJS cycles entered from either side. Split chunks are require-only modules: their whole export table is their surface. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/input-tracker.ts | 4 + .../src/frontend/npm-static-rewrite-host.ts | 8 +- .../src/frontend/npm-static-rewrite-syntax.ts | 10 ++ packages/compiler/src/frontend/program.ts | 91 +++++++----- .../npm/cases/2558-bundled-chunks/main.ts | 29 ++++ .../fixtures/npm/cases/2559-cjs-cycle/main.ts | 11 ++ .../2560-cjs-cycle-partner-first/main.ts | 11 ++ .../cases/2561-cjs-cycle-early-read/main.ts | 6 + .../npm/node_modules/gtchunk/dist/index.d.ts | 4 + .../npm/node_modules/gtchunk/dist/index.js | 132 ++++++++++++++++++ .../npm/node_modules/gtchunk/package.json | 9 ++ .../node_modules/gtchunk17/dist/index.d.ts | 4 + .../npm/node_modules/gtchunk17/dist/index.js | 132 ++++++++++++++++++ .../npm/node_modules/gtchunk17/package.json | 9 ++ .../node_modules/gtchunkws/dist/index.d.ts | 4 + .../npm/node_modules/gtchunkws/dist/index.js | 1 + .../npm/node_modules/gtchunkws/package.json | 9 ++ .../npm/node_modules/gtcjsdist/dist/index.cjs | 43 ++++++ .../node_modules/gtcjsdist/dist/index.d.cts | 1 + .../npm/node_modules/gtcjsdist/package.json | 10 ++ .../npm/node_modules/gtcycle/dist/append.d.ts | 1 + .../npm/node_modules/gtcycle/dist/append.js | 30 ++++ .../npm/node_modules/gtcycle/dist/index.d.ts | 4 + .../npm/node_modules/gtcycle/dist/index.js | 44 ++++++ .../npm/node_modules/gtcycle/dist/merge.d.ts | 1 + .../npm/node_modules/gtcycle/dist/merge.js | 30 ++++ .../npm/node_modules/gtcycle/package.json | 6 + .../node_modules/gtcyclebad/dist/index.d.ts | 2 + .../npm/node_modules/gtcyclebad/dist/index.js | 33 +++++ .../node_modules/gtcyclebad/dist/reader.js | 33 +++++ .../npm/node_modules/gtcyclebad/package.json | 6 + 31 files changed, 681 insertions(+), 37 deletions(-) create mode 100644 tests/fixtures/npm/cases/2558-bundled-chunks/main.ts create mode 100644 tests/fixtures/npm/cases/2559-cjs-cycle/main.ts create mode 100644 tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts create mode 100644 tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunk/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunk/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtchunk/package.json create mode 100644 tests/fixtures/npm/node_modules/gtchunk17/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunk17/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtchunk17/package.json create mode 100644 tests/fixtures/npm/node_modules/gtchunkws/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunkws/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtchunkws/package.json create mode 100644 tests/fixtures/npm/node_modules/gtcjsdist/dist/index.cjs create mode 100644 tests/fixtures/npm/node_modules/gtcjsdist/dist/index.d.cts create mode 100644 tests/fixtures/npm/node_modules/gtcjsdist/package.json create mode 100644 tests/fixtures/npm/node_modules/gtcycle/dist/append.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcycle/dist/append.js create mode 100644 tests/fixtures/npm/node_modules/gtcycle/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcycle/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtcycle/dist/merge.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcycle/dist/merge.js create mode 100644 tests/fixtures/npm/node_modules/gtcycle/package.json create mode 100644 tests/fixtures/npm/node_modules/gtcyclebad/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcyclebad/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclebad/dist/reader.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclebad/package.json diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index b710988a3..fa2e92568 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -174,6 +174,10 @@ export function setVirtualSource(path: string, text: string): void { virtualSources.set(resolve(path), text); } +export function isVirtualSource(path: string): boolean { + return virtualSources.has(resolve(path)); +} + export function clearVirtualSources(): void { virtualSources.clear(); } diff --git a/packages/compiler/src/frontend/npm-static-rewrite-host.ts b/packages/compiler/src/frontend/npm-static-rewrite-host.ts index d3b2b9ad4..524239901 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-host.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-host.ts @@ -2,7 +2,12 @@ import { dirname, join, resolve as resolvePath } from "node:path"; import { cjsLexedExportsOfFile, cjsVisibleNames } from "./cjs-syntax.js"; import type { Ts7SourceParser } from "./ts7/source-parser.js"; -import { trackedDirectoryExists, trackedFileExists, trackedReadFile } from "./input-tracker.js"; +import { + isVirtualSource, + trackedDirectoryExists, + trackedFileExists, + trackedReadFile, +} from "./input-tracker.js"; import { resolveExports } from "./resolve.js"; import { packageNameOfSpecifier } from "./workspace-registry.js"; import { isBundlerCjsCandidate, rewriteBundlerCjsSyntax } from "./npm-static-rewrite-syntax.js"; @@ -168,6 +173,7 @@ export function rewriteBundlerCjsWithParser( if (!isBundlerCjsCandidate(source)) return null; return rewriteBundlerCjsSyntax(parser.parse(filePath, source, "js"), filePath, { liveExportTable, + requireOnly: isVirtualSource, requireTargetEsModuleStamped: (from, spec) => requireTargetEsModuleStamped(parser, from, spec), starTargetNames: (from, spec) => { const target = resolveRelativeCjs(from, spec); diff --git a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts index 49467782f..e0534fc2d 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts @@ -17,6 +17,11 @@ export interface CjsRewriteHost { * module's code (liveExportTableFirst): a module of a require cycle that * captured the export object early observes exactly that table. */ liveExportTable?: ((filePath: string) => void) | undefined; + /** True for a module only ever loaded through require() (a bundle's + * CommonJS chunk split into its own module): no ES-module importer + * exists, so Node's lexer-visible name set bounds nothing and every + * name the export plumbing defines is part of its surface. */ + requireOnly?: ((filePath: string) => boolean) | undefined; } /** True when esbuild's export object (`module.exports = __toCommonJS(T)` @@ -711,6 +716,11 @@ export function rewriteBundlerCjsSyntax( } const lexed = cjsLexedExportsOfFile(sf); + if (host.requireOnly?.(filePath) === true) { + for (const t of exportTables) for (const [name] of t.entries) lexed.exports.add(name); + for (const d of definePropEntries) lexed.exports.add(d.name); + for (const m of memberStmts) for (const name of m.names) lexed.exports.add(name); + } let emitTable = sawBundleShape && (lexed.exports.size > 0 || lexed.reexports.length > 0); const starSpecs: string[] = []; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index d06e966f3..12cdfd76c 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3188,7 +3188,7 @@ function cjsCycleRefusal7( const dep = resolveImport7(program, m, req.spec, "require"); if (dep === null || !members.has(dep)) continue; const reached = requireBindingEarlyUse7(program, m, stmts.length, req.decl, [], (id) => - functionExportRead7(program, id, members), + functionExportRead7(id, dep), ); if (reached !== null) { return `the top-level code of ${m.fileName} can use its binding '${reached.text}' of a module of the cycle (${lineOf(reached)}) while the cycle initializes`; @@ -3199,16 +3199,12 @@ function cjsCycleRefusal7( return null; } -/** True for a plain read `binding.name` (not a call, not a write) whose - * export resolves to a top-level function declaration of a module of the - * cycle that nothing reassigns. Every member settles its exports before +/** True for a plain read `binding.name` (not a call, not a write) of the + * cycle member `dep` whose export `name` is a top-level function + * declaration nothing reassigns. Every member settles its exports before * entering the cycle, so Node reads that same function whenever the read * runs, and the compiled read resolves to the declaration itself. */ -function functionExportRead7( - program: ts.Program, - id: ts.Identifier, - members: ReadonlySet, -): boolean { +function functionExportRead7(id: ts.Identifier, dep: ts.SourceFile): boolean { const access = id.parent; if (!ts.isPropertyAccessExpression(access) || access.expression !== id) return false; let use: ts.Node = access; @@ -3226,37 +3222,60 @@ function functionExportRead7( parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ) return false; - const checker = program.getTypeChecker(); - const resolved = (sym: ts.Symbol | undefined, depth: number): ts.FunctionDeclaration | null => { - if (sym === undefined || depth > 4) return null; - if ((sym.flags & ts.SymbolFlags.Alias) !== 0) sym = checker.getAliasedSymbol(sym); - for (const d of checker.declarationsOf(sym)) { - if (ts.isFunctionDeclaration(d)) return d; - if (ts.isPropertyAssignment(d) && ts.isIdentifier(d.initializer)) - return resolved(checker.getSymbolAtLocation(d.initializer), depth + 1); - if (ts.isShorthandPropertyAssignment(d)) - return resolved(checker.getShorthandAssignmentValueSymbol(d), depth + 1); + const name = access.name.text; + // The exported value's identifier: a table entry or a member export. + const values: ts.Expression[] = []; + for (const stmt of dep.statements.nodes) { + if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) continue; + const e = stmt.expression; + if (e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue; + if (isModuleExportsAccess(e.left)) { + if (!ts.isObjectLiteralExpression(e.right)) return false; + for (const p of e.right.properties.nodes) { + if (ts.isSpreadAssignment(p)) return false; + if (ts.isShorthandPropertyAssignment(p) && p.name.text === name) + values.push(p.name as ts.Identifier); + else if ( + ts.isPropertyAssignment(p) && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && + p.name.text === name + ) + values.push(p.initializer); + } + } else if ( + ts.isPropertyAccessExpression(e.left) && + e.left.name.text === name && + ((ts.isIdentifier(e.left.expression) && e.left.expression.text === "exports") || + isModuleExportsAccess(e.left.expression)) + ) { + values.push(e.right); } - return null; - }; - const fn = resolved(checker.getSymbolAtLocation(access.name), 0); - if (fn === null || fn.name === undefined || !members.has(fn.getSourceFile())) return false; - if (!ts.isSourceFile(fn.parent)) return false; - const name = fn.name.text; - let reassigned = false; - ts.walkPreorder(fn.getSourceFile(), (n) => { - if (reassigned) return "stop"; - if ( - ts.isBinaryExpression(n) && - ts.isIdentifier(n.left) && - n.left.text === name && - n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - n.operatorToken.kind <= ts.SyntaxKind.LastAssignment + } + const value = values.length === 1 ? values[0]! : null; + if (value === null || !ts.isIdentifier(value)) return false; + const fnName = value.text; + if ( + !dep.statements.nodes.some( + (st) => ts.isFunctionDeclaration(st) && st.name?.text === fnName && st.body !== undefined, ) - reassigned = true; + ) + return false; + let rebound = false; + ts.walkPreorder(dep, (n) => { + if (rebound) return "stop"; + if ( + (ts.isBinaryExpression(n) && + ts.isIdentifier(n.left) && + n.left.text === fnName && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) || + (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === fnName) || + (ts.isFunctionDeclaration(n) && n.name?.text === fnName && !ts.isSourceFile(n.parent)) + ) + rebound = true; return undefined; }); - return !reassigned; + return !rebound; } /* ── exact cyclic initialization ───────────────────────────────────────── diff --git a/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts b/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts new file mode 100644 index 000000000..5cbbc3dbc --- /dev/null +++ b/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts @@ -0,0 +1,29 @@ +// esbuild's bundled CommonJS chunks: each package below is real esbuild +// output (0.25 unminified, 0.25 whitespace+syntax minified, 0.17) of one +// source that bundles CommonJS modules through the __commonJS helper. +// Every chunk compiles as its own CommonJS module: it runs once, at its +// first require (the top-level interop imports run while the bundle +// loads; `lazy` requires its chunk only when called), chunk-to-chunk +// requires share one module instance, an esbuild-built dist bundled as a +// chunk keeps its suffixed helpers, and the external dependency passes +// through the __toESM interop. gtcjsdist is a "type": "module" package +// whose CommonJS entry is a .cjs bundle using the node-mode interop. +import { describe, external, lazy, nested } from "gtchunk"; +import { describe as dws, external as ews, lazy as lws, nested as nws } from "gtchunkws"; +import { describe as d17, external as e17, lazy as l17, nested as n17 } from "gtchunk17"; +import { viaNodeMode } from "gtcjsdist"; + +console.log("main start"); +console.log("plain", describe()); +console.log("plain", external()); +console.log("plain", nested()); +console.log("plain", lazy(), lazy()); +console.log("minified", dws()); +console.log("minified", ews()); +console.log("minified", nws()); +console.log("minified", lws(), lws()); +console.log("older", d17()); +console.log("older", e17()); +console.log("older", n17()); +console.log("older", l17(), l17()); +console.log(viaNodeMode()); diff --git a/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts b/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts new file mode 100644 index 000000000..042102b64 --- /dev/null +++ b/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts @@ -0,0 +1,11 @@ +// A require cycle between esbuild's per-file CommonJS outputs (index ↔ +// merge, index ↔ append). Each file installs its live getter table +// before its first require, so a partner that loads while index.js is +// still initializing sees index's partially initialized exports, and both +// partners call back into index from their functions. index.js re-exports +// mergeAll from merge.js through its getter table. +import { run, mergeAll } from "gtcycle"; + +console.log(run([" a ", "", "@x"], ["b", "@keep"])); +console.log(run(["c"], [])); +console.log(mergeAll(["@keep", " d"], ["@drop"]).join("|")); diff --git a/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts b/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts new file mode 100644 index 000000000..1ed90d432 --- /dev/null +++ b/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts @@ -0,0 +1,11 @@ +// The same esbuild CommonJS cycle entered through a partner: merge.js +// loads first, requires index.js, which requires merge.js back while +// merge is still initializing (its getter table is already installed) and +// re-exports mergeAll from it: the re-export reads merge's hoisted +// function while merge is still initializing. +import { mergeAll } from "gtcycle/dist/merge.js"; +import { run, mergeAll as reexported } from "gtcycle"; + +console.log(mergeAll([" x "], ["@keep"]).join(",")); +console.log(reexported === mergeAll); +console.log(run(["y"], ["@z"])); diff --git a/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts b/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts new file mode 100644 index 000000000..3efeb9eb1 --- /dev/null +++ b/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts @@ -0,0 +1,6 @@ +// A require cycle whose partner reads a const export of index.js while +// index is still initializing: Node throws a ReferenceError (the binding +// is in its temporal dead zone), and scriptc refuses the cycle. +import { show } from "gtcyclebad"; + +console.log(show()); diff --git a/tests/fixtures/npm/node_modules/gtchunk/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunk/dist/index.d.ts new file mode 100644 index 000000000..bd3d4e582 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunk/dist/index.d.ts @@ -0,0 +1,4 @@ +export declare function describe(): string; +export declare function external(): string; +export declare function nested(): string; +export declare function lazy(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunk/dist/index.js b/tests/fixtures/npm/node_modules/gtchunk/dist/index.js new file mode 100644 index 000000000..7820e3ae4 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunk/dist/index.js @@ -0,0 +1,132 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// chunk/vendor/settings/counter.js +var require_counter = __commonJS({ + "chunk/vendor/settings/counter.js"(exports2, module2) { + var n = 0; + console.log("counter init"); + module2.exports = { + next() { + n += 1; + return n; + } + }; + } +}); + +// chunk/vendor/settings/index.js +var require_settings = __commonJS({ + "chunk/vendor/settings/index.js"(exports2) { + "use strict"; + console.log("settings init"); + var counter = require_counter(); + exports2.LABEL = "bundled"; + exports2.VERSION = 2; + exports2.bump = function() { + return counter.next(); + }; + } +}); + +// chunk/vendor/prebuilt/index.js +var require_prebuilt = __commonJS({ + "chunk/vendor/prebuilt/index.js"(exports2, module2) { + var __defProp2 = Object.defineProperty; + var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropNames2 = Object.getOwnPropertyNames; + var __hasOwnProp2 = Object.prototype.hasOwnProperty; + var __export2 = (target, all) => { + for (var name in all) + __defProp2(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps2 = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames2(from)) + if (!__hasOwnProp2.call(to, key) && key !== except) + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS2 = (mod) => __copyProps2(__defProp2({}, "__esModule", { value: true }), mod); + var prebuilt_exports = {}; + __export2(prebuilt_exports, { + PREBUILT_TAG: () => PREBUILT_TAG2, + greet: () => greet2 + }); + module2.exports = __toCommonJS2(prebuilt_exports); + function greet2(name) { + return "hello " + name; + } + var PREBUILT_TAG2 = "prebuilt"; + } +}); + +// chunk/vendor/lazy.js +var require_lazy = __commonJS({ + "chunk/vendor/lazy.js"(exports2, module2) { + console.log("lazy init"); + module2.exports = { value: "lazy-value" }; + } +}); + +// chunk/src/index.ts +var index_exports = {}; +__export(index_exports, { + describe: () => describe, + external: () => external, + lazy: () => lazy, + nested: () => nested +}); +module.exports = __toCommonJS(index_exports); +var import_settings = __toESM(require_settings()); +var core = __toESM(require("gtcore")); +var import_gtcore = __toESM(require("gtcore")); +var import_prebuilt = __toESM(require_prebuilt()); +function describe() { + return import_settings.LABEL + "@" + import_settings.default.VERSION + " bump " + (0, import_settings.bump)() + " " + (0, import_settings.bump)(); +} +function external() { + return core.tag + ":" + core.label(1) + ":" + import_gtcore.default.label(2); +} +function nested() { + return (0, import_prebuilt.greet)("chunk") + " " + import_prebuilt.PREBUILT_TAG; +} +function lazy() { + return require_lazy().value; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + describe, + external, + lazy, + nested +}); diff --git a/tests/fixtures/npm/node_modules/gtchunk/package.json b/tests/fixtures/npm/node_modules/gtchunk/package.json new file mode 100644 index 000000000..579ad6cab --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunk/package.json @@ -0,0 +1,9 @@ +{ + "name": "gtchunk", + "version": "1.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "gtcore": "1.0.0" + } +} diff --git a/tests/fixtures/npm/node_modules/gtchunk17/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunk17/dist/index.d.ts new file mode 100644 index 000000000..bd3d4e582 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunk17/dist/index.d.ts @@ -0,0 +1,4 @@ +export declare function describe(): string; +export declare function external(): string; +export declare function nested(): string; +export declare function lazy(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunk17/dist/index.js b/tests/fixtures/npm/node_modules/gtchunk17/dist/index.js new file mode 100644 index 000000000..afda6af56 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunk17/dist/index.js @@ -0,0 +1,132 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// chunk/vendor/settings/counter.js +var require_counter = __commonJS({ + "chunk/vendor/settings/counter.js"(exports, module2) { + var n = 0; + console.log("counter init"); + module2.exports = { + next() { + n += 1; + return n; + } + }; + } +}); + +// chunk/vendor/settings/index.js +var require_settings = __commonJS({ + "chunk/vendor/settings/index.js"(exports) { + "use strict"; + console.log("settings init"); + var counter = require_counter(); + exports.LABEL = "bundled"; + exports.VERSION = 2; + exports.bump = function() { + return counter.next(); + }; + } +}); + +// chunk/vendor/prebuilt/index.js +var require_prebuilt = __commonJS({ + "chunk/vendor/prebuilt/index.js"(exports, module2) { + var __defProp2 = Object.defineProperty; + var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropNames2 = Object.getOwnPropertyNames; + var __hasOwnProp2 = Object.prototype.hasOwnProperty; + var __export2 = (target, all) => { + for (var name in all) + __defProp2(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps2 = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames2(from)) + if (!__hasOwnProp2.call(to, key) && key !== except) + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS2 = (mod) => __copyProps2(__defProp2({}, "__esModule", { value: true }), mod); + var prebuilt_exports = {}; + __export2(prebuilt_exports, { + PREBUILT_TAG: () => PREBUILT_TAG2, + greet: () => greet2 + }); + module2.exports = __toCommonJS2(prebuilt_exports); + function greet2(name) { + return "hello " + name; + } + var PREBUILT_TAG2 = "prebuilt"; + } +}); + +// chunk/vendor/lazy.js +var require_lazy = __commonJS({ + "chunk/vendor/lazy.js"(exports, module2) { + console.log("lazy init"); + module2.exports = { value: "lazy-value" }; + } +}); + +// chunk/src/index.ts +var src_exports = {}; +__export(src_exports, { + describe: () => describe, + external: () => external, + lazy: () => lazy, + nested: () => nested +}); +module.exports = __toCommonJS(src_exports); +var import_settings = __toESM(require_settings()); +var core = __toESM(require("gtcore")); +var import_gtcore = __toESM(require("gtcore")); +var import_prebuilt = __toESM(require_prebuilt()); +function describe() { + return import_settings.LABEL + "@" + import_settings.default.VERSION + " bump " + (0, import_settings.bump)() + " " + (0, import_settings.bump)(); +} +function external() { + return core.tag + ":" + core.label(1) + ":" + import_gtcore.default.label(2); +} +function nested() { + return (0, import_prebuilt.greet)("chunk") + " " + import_prebuilt.PREBUILT_TAG; +} +function lazy() { + return require_lazy().value; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + describe, + external, + lazy, + nested +}); diff --git a/tests/fixtures/npm/node_modules/gtchunk17/package.json b/tests/fixtures/npm/node_modules/gtchunk17/package.json new file mode 100644 index 000000000..f01e680c0 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunk17/package.json @@ -0,0 +1,9 @@ +{ + "name": "gtchunk17", + "version": "1.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "gtcore": "1.0.0" + } +} diff --git a/tests/fixtures/npm/node_modules/gtchunkws/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunkws/dist/index.d.ts new file mode 100644 index 000000000..bd3d4e582 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkws/dist/index.d.ts @@ -0,0 +1,4 @@ +export declare function describe(): string; +export declare function external(): string; +export declare function nested(): string; +export declare function lazy(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunkws/dist/index.js b/tests/fixtures/npm/node_modules/gtchunkws/dist/index.js new file mode 100644 index 000000000..f022baf09 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkws/dist/index.js @@ -0,0 +1 @@ +var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf,__hasOwnProp=Object.prototype.hasOwnProperty;var __commonJS=(cb,mod)=>function(){return mod||(0,cb[__getOwnPropNames(cb)[0]])((mod={exports:{}}).exports,mod),mod.exports};var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:!0})},__copyProps=(to,from,except,desc)=>{if(from&&typeof from=="object"||typeof from=="function")for(let key of __getOwnPropNames(from))!__hasOwnProp.call(to,key)&&key!==except&&__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable});return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:!0}):target,mod)),__toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:!0}),mod);var require_counter=__commonJS({"chunk/vendor/settings/counter.js"(exports2,module2){var n=0;console.log("counter init");module2.exports={next(){return n+=1,n}}}});var require_settings=__commonJS({"chunk/vendor/settings/index.js"(exports2){"use strict";console.log("settings init");var counter=require_counter();exports2.LABEL="bundled";exports2.VERSION=2;exports2.bump=function(){return counter.next()}}});var require_prebuilt=__commonJS({"chunk/vendor/prebuilt/index.js"(exports2,module2){var __defProp2=Object.defineProperty,__getOwnPropDesc2=Object.getOwnPropertyDescriptor,__getOwnPropNames2=Object.getOwnPropertyNames,__hasOwnProp2=Object.prototype.hasOwnProperty,__export2=(target,all)=>{for(var name in all)__defProp2(target,name,{get:all[name],enumerable:!0})},__copyProps2=(to,from,except,desc)=>{if(from&&typeof from=="object"||typeof from=="function")for(let key of __getOwnPropNames2(from))!__hasOwnProp2.call(to,key)&&key!==except&&__defProp2(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc2(from,key))||desc.enumerable});return to},__toCommonJS2=mod=>__copyProps2(__defProp2({},"__esModule",{value:!0}),mod),prebuilt_exports={};__export2(prebuilt_exports,{PREBUILT_TAG:()=>PREBUILT_TAG2,greet:()=>greet2});module2.exports=__toCommonJS2(prebuilt_exports);function greet2(name){return"hello "+name}var PREBUILT_TAG2="prebuilt"}});var require_lazy=__commonJS({"chunk/vendor/lazy.js"(exports2,module2){console.log("lazy init");module2.exports={value:"lazy-value"}}});var index_exports={};__export(index_exports,{describe:()=>describe,external:()=>external,lazy:()=>lazy,nested:()=>nested});module.exports=__toCommonJS(index_exports);var import_settings=__toESM(require_settings()),core=__toESM(require("gtcore")),import_gtcore=__toESM(require("gtcore")),import_prebuilt=__toESM(require_prebuilt());function describe(){return import_settings.LABEL+"@"+import_settings.default.VERSION+" bump "+(0,import_settings.bump)()+" "+(0,import_settings.bump)()}function external(){return core.tag+":"+core.label(1)+":"+import_gtcore.default.label(2)}function nested(){return(0,import_prebuilt.greet)("chunk")+" "+import_prebuilt.PREBUILT_TAG}function lazy(){return require_lazy().value}0&&(module.exports={describe,external,lazy,nested}); diff --git a/tests/fixtures/npm/node_modules/gtchunkws/package.json b/tests/fixtures/npm/node_modules/gtchunkws/package.json new file mode 100644 index 000000000..c1fc1815a --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkws/package.json @@ -0,0 +1,9 @@ +{ + "name": "gtchunkws", + "version": "1.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "gtcore": "1.0.0" + } +} diff --git a/tests/fixtures/npm/node_modules/gtcjsdist/dist/index.cjs b/tests/fixtures/npm/node_modules/gtcjsdist/dist/index.cjs new file mode 100644 index 000000000..8836b5691 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcjsdist/dist/index.cjs @@ -0,0 +1,43 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// cjsdist/src/index.ts +var index_exports = {}; +__export(index_exports, { + viaNodeMode: () => viaNodeMode +}); +module.exports = __toCommonJS(index_exports); +var core = __toESM(require("gtcore"), 1); +var import_gtcore = __toESM(require("gtcore"), 1); +function viaNodeMode() { + return import_gtcore.default.tag + "/" + core.label(7) + "/" + import_gtcore.default.label(8); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + viaNodeMode +}); diff --git a/tests/fixtures/npm/node_modules/gtcjsdist/dist/index.d.cts b/tests/fixtures/npm/node_modules/gtcjsdist/dist/index.d.cts new file mode 100644 index 000000000..6f77b9943 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcjsdist/dist/index.d.cts @@ -0,0 +1 @@ +export declare function viaNodeMode(): string; diff --git a/tests/fixtures/npm/node_modules/gtcjsdist/package.json b/tests/fixtures/npm/node_modules/gtcjsdist/package.json new file mode 100644 index 000000000..c40f3baed --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcjsdist/package.json @@ -0,0 +1,10 @@ +{ + "name": "gtcjsdist", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "dependencies": { + "gtcore": "1.0.0" + } +} diff --git a/tests/fixtures/npm/node_modules/gtcycle/dist/append.d.ts b/tests/fixtures/npm/node_modules/gtcycle/dist/append.d.ts new file mode 100644 index 000000000..e33c31f90 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/dist/append.d.ts @@ -0,0 +1 @@ +export declare function appendAll(xs: string[], tail: string): string[]; diff --git a/tests/fixtures/npm/node_modules/gtcycle/dist/append.js b/tests/fixtures/npm/node_modules/gtcycle/dist/append.js new file mode 100644 index 000000000..cdfeb6c6c --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/dist/append.js @@ -0,0 +1,30 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var append_exports = {}; +__export(append_exports, { + appendAll: () => appendAll +}); +module.exports = __toCommonJS(append_exports); +var import_index = require("./index"); +function appendAll(xs, tail) { + return xs.some(import_index.isHandler) ? xs : xs.concat([tail]); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + appendAll +}); diff --git a/tests/fixtures/npm/node_modules/gtcycle/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcycle/dist/index.d.ts new file mode 100644 index 000000000..4b86308a9 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/dist/index.d.ts @@ -0,0 +1,4 @@ +export { mergeAll } from "./merge"; +export declare function isHandler(x: string): boolean; +export declare function normalize(xs: string[]): string[]; +export declare function run(a: string[], b: string[]): string; diff --git a/tests/fixtures/npm/node_modules/gtcycle/dist/index.js b/tests/fixtures/npm/node_modules/gtcycle/dist/index.js new file mode 100644 index 000000000..92f6d2a39 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/dist/index.js @@ -0,0 +1,44 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + isHandler: () => isHandler, + mergeAll: () => import_merge2.mergeAll, + normalize: () => normalize, + run: () => run +}); +module.exports = __toCommonJS(index_exports); +var import_append = require("./append"); +var import_merge = require("./merge"); +var import_merge2 = require("./merge"); +function isHandler(x) { + return x.startsWith("@"); +} +function normalize(xs) { + return xs.map((x) => x.trim()).filter((x) => x.length > 0); +} +function run(a, b) { + return (0, import_append.appendAll)((0, import_merge.mergeAll)(a, b), "@end").join(","); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + isHandler, + mergeAll, + normalize, + run +}); diff --git a/tests/fixtures/npm/node_modules/gtcycle/dist/merge.d.ts b/tests/fixtures/npm/node_modules/gtcycle/dist/merge.d.ts new file mode 100644 index 000000000..bbe6b6a4d --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/dist/merge.d.ts @@ -0,0 +1 @@ +export declare function mergeAll(a: string[], b: string[]): string[]; diff --git a/tests/fixtures/npm/node_modules/gtcycle/dist/merge.js b/tests/fixtures/npm/node_modules/gtcycle/dist/merge.js new file mode 100644 index 000000000..114e7d8cf --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/dist/merge.js @@ -0,0 +1,30 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var merge_exports = {}; +__export(merge_exports, { + mergeAll: () => mergeAll +}); +module.exports = __toCommonJS(merge_exports); +var import_index = require("./index"); +function mergeAll(a, b) { + return (0, import_index.normalize)(a.concat(b)).filter((x) => !(0, import_index.isHandler)(x) || x === "@keep"); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + mergeAll +}); diff --git a/tests/fixtures/npm/node_modules/gtcycle/package.json b/tests/fixtures/npm/node_modules/gtcycle/package.json new file mode 100644 index 000000000..7d275beef --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcycle/package.json @@ -0,0 +1,6 @@ +{ + "name": "gtcycle", + "version": "1.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtcyclebad/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclebad/dist/index.d.ts new file mode 100644 index 000000000..0fa366319 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebad/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare const LIMIT = 3; +export declare function show(): string; diff --git a/tests/fixtures/npm/node_modules/gtcyclebad/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclebad/dist/index.js new file mode 100644 index 000000000..5bd708a37 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebad/dist/index.js @@ -0,0 +1,33 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclebad/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclebad/dist/reader.js new file mode 100644 index 000000000..a58bdaa51 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebad/dist/reader.js @@ -0,0 +1,33 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const captured = import_index.LIMIT; +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclebad/package.json b/tests/fixtures/npm/node_modules/gtcyclebad/package.json new file mode 100644 index 000000000..a6524f8c1 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebad/package.json @@ -0,0 +1,6 @@ +{ + "name": "gtcyclebad", + "version": "1.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} From 22edb6deb00d31137df32b7b0dceaf80f8960405 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:20:28 -0700 Subject: [PATCH 14/85] Test bundled chunks, cycles and refusals Unit tests run each split bundle against its original under Node and pin the refusal reasons; npm-static tests compile the new cases and compare them with Node byte for byte. Co-Authored-By: Claude Opus 5.5 --- .../npm-static-bundled-chunks.test.ts | 160 ++++++++++++++++++ .../src/frontend/npm-static-bundled-chunks.ts | 18 +- .../src/frontend/npm-static-rewrite-syntax.ts | 2 +- .../npm/node_modules/gtchunk/package.json | 1 + .../npm/node_modules/gtchunk17/package.json | 1 + .../npm/node_modules/gtchunkws/package.json | 1 + .../npm/node_modules/gtcjsdist/package.json | 1 + .../npm/node_modules/gtcycle/package.json | 1 + .../npm/node_modules/gtcyclebad/package.json | 1 + tests/harness/npm-cases.ts | 4 +- tests/harness/npm-static.test.ts | 61 ++++++- 11 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts new file mode 100644 index 000000000..91b4c9763 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -0,0 +1,160 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; +import type { BundledChunkSplit } from "./npm-static-bundled-chunks.js"; + +const keyedHelper = `var __getOwnPropNames = Object.getOwnPropertyNames; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +`; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function split(source: string, file = "/pkg/dist/index.js") { + return nodeFrontendServices().splitCjsChunks(source, file); +} + +function run(file: string) { + const result = spawnSync(process.execPath, [file], { encoding: "utf8" }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +/** Runs the original bundle and its split modules side by side. */ +function expectSameBehavior(source: string): BundledChunkSplit { + const dir = mkdtempSync(join(tmpdir(), "scriptc-chunks-")); + dirs.push(dir); + const original = join(dir, "original.js"); + writeFileSync(original, source); + const bundle = join(dir, "index.js"); + const result = split(source, bundle); + expect(result).not.toBeNull(); + expect(result).not.toHaveProperty("degrade"); + const answer = result as BundledChunkSplit; + writeFileSync(bundle, answer.text); + for (const chunk of answer.chunks) writeFileSync(chunk.path, chunk.source); + // Respelled calls and blanked declarations keep the line structure. + expect(answer.text.split("\n")).toHaveLength(source.split("\n").length); + const oracle = run(original); + expect(oracle.status).toBe(0); + expect(run(bundle)).toEqual(oracle); + return answer; +} + +test("chunks run once, at their first require, with shared module instances", () => { + const answer = expectSameBehavior(`"use strict"; +${keyedHelper} +var require_b = __commonJS({ + "b.js"(exports2, module2) { + console.log("b init"); + module2.exports = { n: 1 }; + } +}); +var require_a = __commonJS({ + "a.js"(exports2) { + console.log("a init"); + var b = require_b(); + exports2.value = b.n + 1; + exports2.keys = Object.keys({ exports2 }); + } +}); +console.log("start"); +console.log(require_a().value, require_a().value, require_b().n, require_a().keys); +`); + expect(answer.chunks.map((c) => basename(c.path))).toEqual([ + "index.js.require_b.cjs", + "index.js.require_a.cjs", + ]); + expect(answer.text).not.toContain("require_a()"); +}); + +test("the arrow helper form and minified spellings split the same way", () => { + expectSameBehavior( + `var __commonJS=(cb,mod)=>()=>(mod||cb((mod={exports:{}}).exports,mod),mod.exports);` + + `var require_x=__commonJS((exports,module)=>{module.exports={v:"x"}});` + + `console.log(require_x().v,require_x()===require_x());`, + ); +}); + +test("esbuild 0.11's Object.keys spelling is recognized", () => { + expectSameBehavior(`var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[Object.keys(cb)[0]])((mod = {exports: {}}).exports, mod), mod.exports; +}; +var require_y = __commonJS({ + "y.js"(exports) { + exports.y = 3; + } +}); +console.log(require_y().y); +`); +}); + +test("pure helpers a chunk reads travel with it", () => { + const answer = expectSameBehavior(`${keyedHelper}var __defProp = Object.defineProperty; +var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); +var require_named = __commonJS({ + "named.js"(exports2, module2) { + module2.exports = __name(function () {}, "renamed"); + } +}); +console.log(require_named().name); +`); + const chunk = answer.chunks[0]!.source; + expect(chunk).toContain("var __name ="); + expect(chunk).toContain("var __defProp = Object.defineProperty;"); +}); + +test.each([ + ["reads its factory's receiver", `console.log(this);`], + ["reads its factory's arguments object", `console.log(arguments.length);`], + ["returns from its top level", `if (exports2) return;`], + ["uses its module object beyond module.exports", `console.log(module2.id);`], + ["reads __filename", `console.log(__filename);`], + ["rebinds its module parameters", `function f(module2) { return module2; }`], + ["reads the bundle's top-level binding 'state'", `console.log(state);`], +])("a chunk that %s keeps the package refused", (reason, statement) => { + const result = split(`${keyedHelper}var state = { n: 1 }; +var require_c = __commonJS({ + "c.js"(exports2, module2) { + ${statement} + } +}); +require_c(); +`); + expect(result).toEqual({ degrade: expect.stringContaining(reason) as string }); +}); + +test("a factory used other than as a direct call keeps the package refused", () => { + const result = + split(`${keyedHelper}var require_d = __commonJS({ "d.js"(exports2) { exports2.d = 1; } }); +var load = require_d; +load(); +`); + expect(result).toEqual({ + degrade: expect.stringContaining("used other than as a direct call") as string, + }); +}); + +test("an unrecognized helper body keeps the package refused", () => { + const result = + split(`var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod); +var require_e = __commonJS((exports) => { exports.e = 1; }); +require_e(); +`); + expect(result).toEqual({ degrade: expect.stringContaining("__commonJS helper") as string }); +}); + +test("files without chunks and ES-module bundles are left alone", () => { + expect(split("exports.x = 1;\n")).toBeNull(); + expect( + split(`${keyedHelper}var require_f = __commonJS({ "f.js"(e) { e.f = 1; } }); +export default require_f(); +`), + ).toBeNull(); +}); diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index e1256e59f..8b065ccda 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -157,7 +157,8 @@ function isWritten(id: ts.Identifier): boolean { * keyed: `(CB, MOD) => function [name]() { return ; }` — the * factory is the only method of an object literal; * arrow: `(CB, MOD) => () => ()` — the factory itself. */ -const KEYED_RETURN = "MOD || (0, CB[KEYS(CB)[0]])((MOD = { exports: {} }).exports, MOD), MOD.exports"; +const KEYED_RETURN = + "MOD || (0, CB[KEYS(CB)[0]])((MOD = { exports: {} }).exports, MOD), MOD.exports"; const ARROW_BODY = "(MOD || CB((MOD = { exports: {} }).exports, MOD), MOD.exports)"; function template(text: string, cb: string, mod: string, keys: string): string | null { @@ -255,7 +256,7 @@ export function splitBundledChunks( if (!source.includes("__commonJS")) return null; // ES-module bundles have no require: their chunks keep the existing // treatment (the Bun factory recognizer, or the ESM interop refusal). - if (!/\.cjs$/.test(filePath) && ts.isExternalModule(sf)) return null; + if (!filePath.endsWith(".cjs") && ts.isExternalModule(sf)) return null; const top = new Map(); const duplicate = new Set(); @@ -280,7 +281,8 @@ export function splitBundledChunks( const written = new Set(); for (const r of allRefs) if (isWritten(r)) written.add(r.text); const declaredAnywhere = declaredNames(sf); - if (declaredAnywhere.has("Object") || written.has("Object")) return { degrade: CHUNK_SHAPE_DEGRADE }; + if (declaredAnywhere.has("Object") || written.has("Object")) + return { degrade: CHUNK_SHAPE_DEGRADE }; const stable = (name: string): boolean => !duplicate.has(name) && !written.has(name); const ownNames = (name: string): boolean => { const t = top.get(name); @@ -440,9 +442,7 @@ function applyEdits(source: string, from: number, to: number, edits: Edit[]): st const replaced = source.slice(e.start, e.end); // A respelled span keeps its line breaks after the replacement. text += - e.text === "" - ? replaced.replace(/[^\r\n]/g, " ") - : e.text + replaced.replace(/[^\n]/g, ""); + e.text === "" ? replaced.replace(/[^\r\n]/g, " ") : e.text + replaced.replace(/[^\n]/g, ""); at = e.end; } return text + source.slice(at, to); @@ -469,7 +469,11 @@ function chunkSource( const body = chunk.body; const local = declaredNames(body); for (const p of chunk.params) { - if (local.has(p) || (p !== "exports" && local.has("exports")) || (p !== "module" && local.has("module"))) + if ( + local.has(p) || + (p !== "exports" && local.has("exports")) || + (p !== "module" && local.has("module")) + ) return why( "rebinds its module parameters inside its body (an identifier-minified bundle the recognizer cannot respell)", ); diff --git a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts index e0534fc2d..fb591379b 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts @@ -539,7 +539,7 @@ export function rewriteBundlerCjsSyntax( // TypeScript forces every `.cjs` file to be an external module // (isFileForcedToBeModuleByFormat), yet Node always loads one as // CommonJS: the extension, not that flag, decides the format there. - if (!/\.cjs$/.test(filePath) && ts.isExternalModule(sf)) { + if (!filePath.endsWith(".cjs") && ts.isExternalModule(sf)) { const bundled = rewriteBundledFunctionImports(source, sf); if (bundled !== null) return bundled; return source.includes("__toESM(") diff --git a/tests/fixtures/npm/node_modules/gtchunk/package.json b/tests/fixtures/npm/node_modules/gtchunk/package.json index 579ad6cab..2812a5188 100644 --- a/tests/fixtures/npm/node_modules/gtchunk/package.json +++ b/tests/fixtures/npm/node_modules/gtchunk/package.json @@ -1,6 +1,7 @@ { "name": "gtchunk", "version": "1.0.0", + "description": "esbuild 0.25.10 output (--bundle --platform=node --format=cjs --external:gtcore): bundled CommonJS modules behind the __commonJS helper, including an esbuild-built dist with suffixed helpers", "main": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { diff --git a/tests/fixtures/npm/node_modules/gtchunk17/package.json b/tests/fixtures/npm/node_modules/gtchunk17/package.json index f01e680c0..73761f181 100644 --- a/tests/fixtures/npm/node_modules/gtchunk17/package.json +++ b/tests/fixtures/npm/node_modules/gtchunk17/package.json @@ -1,6 +1,7 @@ { "name": "gtchunk17", "version": "1.0.0", + "description": "the gtchunk source bundled by esbuild 0.17.19: chunk factories take (exports, module2)", "main": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { diff --git a/tests/fixtures/npm/node_modules/gtchunkws/package.json b/tests/fixtures/npm/node_modules/gtchunkws/package.json index c1fc1815a..5502f80a0 100644 --- a/tests/fixtures/npm/node_modules/gtchunkws/package.json +++ b/tests/fixtures/npm/node_modules/gtchunkws/package.json @@ -1,6 +1,7 @@ { "name": "gtchunkws", "version": "1.0.0", + "description": "the gtchunk source bundled by esbuild 0.25.10 with --minify-whitespace --minify-syntax: helpers share var statements", "main": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { diff --git a/tests/fixtures/npm/node_modules/gtcjsdist/package.json b/tests/fixtures/npm/node_modules/gtcjsdist/package.json index c40f3baed..8b416fca1 100644 --- a/tests/fixtures/npm/node_modules/gtcjsdist/package.json +++ b/tests/fixtures/npm/node_modules/gtcjsdist/package.json @@ -1,6 +1,7 @@ { "name": "gtcjsdist", "version": "1.0.0", + "description": "a type-module package whose CommonJS entry is an esbuild 0.25.10 .cjs bundle using the node-mode __toESM interop", "type": "module", "main": "./dist/index.cjs", "types": "./dist/index.d.cts", diff --git a/tests/fixtures/npm/node_modules/gtcycle/package.json b/tests/fixtures/npm/node_modules/gtcycle/package.json index 7d275beef..5b41b8a52 100644 --- a/tests/fixtures/npm/node_modules/gtcycle/package.json +++ b/tests/fixtures/npm/node_modules/gtcycle/package.json @@ -1,6 +1,7 @@ { "name": "gtcycle", "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output (no bundling) of three modules that import each other", "main": "./dist/index.js", "types": "./dist/index.d.ts" } diff --git a/tests/fixtures/npm/node_modules/gtcyclebad/package.json b/tests/fixtures/npm/node_modules/gtcyclebad/package.json index a6524f8c1..793c97fc9 100644 --- a/tests/fixtures/npm/node_modules/gtcyclebad/package.json +++ b/tests/fixtures/npm/node_modules/gtcyclebad/package.json @@ -1,6 +1,7 @@ { "name": "gtcyclebad", "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output of a cycle whose partner reads a const binding before its declaration runs", "main": "./dist/index.js", "types": "./dist/index.d.ts" } diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 4dda0b9ad..8562ac24f 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,12 +19,12 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469 and 2556-2557 are the --npm-static bundler-emitted-CJS + // 2465-2469 and 2556-2561 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[67])-[^/]+\/main\.ts$/.test(entry)) + .filter((entry) => !/\/(246[5-9]|255[6-9]|256[01])-[^/]+\/main\.ts$/.test(entry)) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 356d2fa6d..b908f0bd9 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,8 +76,8 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2557) - ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,json}")).sort(), + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2561) + ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ]; for (const f of inputs) hash.update(f).update(readFileSync(f)); const key = hash @@ -1389,6 +1389,63 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 120_000); + /* ── 2558-2561: esbuild's bundled CommonJS chunks and per-file CommonJS + * cycles ─ every __commonJS chunk compiles as its own CommonJS module + * (evaluated once, at its first require); a require cycle between + * esbuild outputs whose live getter tables install before their first + * require initializes exactly like Node, and a cycle that reads a + * binding in its temporal dead zone stays refused. */ + async function expectNodeParity(entry: string, packages: string[]): Promise { + const { coverage } = analyze(entry, { npmStatic: packages }); + expect(coverage.npmStatic).toEqual(packages.map((pkg) => ({ package: pkg, status: "static" }))); + expect(coverage.preflightFailed).toBe(false); + expect(coverage.diagnostics).toHaveLength(0); + const binary = await buildStatic(entry, packages); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), + ]); + nativeRes.stderr = comparableStderr(nativeRes.stderr); + expect(nativeRes).toEqual(nodeRes); + expect(nativeRes.exitCode).toBe(0); + } + + // 2558: esbuild 0.25 (plain and whitespace/syntax minified) and 0.17 + // CommonJS bundles of one source, plus a .cjs bundle in a type-module + // package with the node-mode interop. + test("bundled CommonJS chunks 2558-bundled-chunks compile statically and byte-match Node", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2558-bundled-chunks/main.ts"), [ + "gtchunk", + "gtchunkws", + "gtchunk17", + "gtcjsdist", + "gtcore", + ]); + }, 180_000); + + test.each(["2559-cjs-cycle", "2560-cjs-cycle-partner-first"])( + "the esbuild CommonJS cycle %s compiles statically and byte-matches Node", + async (name) => { + await expectNodeParity(join(fixturesRoot, `npm/cases/${name}/main.ts`), ["gtcycle"]); + }, + 180_000, + ); + + // 2561: Node throws a ReferenceError there; the cycle keeps SC1016. + test("a CommonJS cycle that reads a binding before its declaration stays refused", () => { + const entry = join(fixturesRoot, "npm/cases/2561-cjs-cycle-early-read/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtcyclebad"] }); + expect(coverage.npmStatic).toEqual([ + { + package: "gtcyclebad", + status: "refused", + detail: expect.stringMatching( + /circular imports .*can use its binding 'import_index' of a module of the cycle/, + ) as string, + }, + ]); + }, 120_000); + test("published type-only declarations coexist with a static runtime bundle", async () => { const entry = join(fixturesRoot, "npm/cases/2469-bundle-offender/main.ts"); const { coverage } = analyze(entry, { npmStatic: ["gtghost"] }); From bda597034323f12c2757ebede5334021cfb64653 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:32:22 -0700 Subject: [PATCH 15/85] Name identifier-minified and ES-module chunk bundles in refusals An identifier-minified esbuild bundle keeps the __commonJS helper's shape but loses the names the recognizers verify; an ES-module bundle of CommonJS chunks has no require to route them through. Both now refuse with a message naming the construct. Co-Authored-By: Claude Opus 5.5 --- .../npm-static-bundled-chunks.test.ts | 22 ++++++++++++++++++ .../src/frontend/npm-static-bundled-chunks.ts | 23 +++++++++++++++++-- .../src/frontend/npm-static-rewrite-syntax.ts | 15 ++++++------ packages/compiler/src/frontend/services.ts | 8 +++++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts index 91b4c9763..3ef430102 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterAll, expect, test } from "vitest"; import { nodeFrontendServices } from "./services-node.js"; +import { rewriteBundlerCjsExports } from "./npm-static-rewrite.js"; import type { BundledChunkSplit } from "./npm-static-bundled-chunks.js"; const keyedHelper = `var __getOwnPropNames = Object.getOwnPropertyNames; @@ -158,3 +159,24 @@ export default require_f(); `), ).toBeNull(); }); + +test("an identifier-minified bundle names its lost helper names", () => { + // esbuild --minify output: the helper shapes survive, their names do not. + const result = split( + `var c=(r,e)=>()=>(e||r((e={exports:{}}).exports,e),e.exports);` + + `var x=c((t,m)=>{m.exports={v:1}});console.log(x().v);`, + ); + expect(result).toEqual({ degrade: expect.stringContaining("identifier-minified") as string }); +}); + +test("an ES-module bundle of CommonJS chunks names the chunk interop", () => { + const result = rewriteBundlerCjsExports( + `${keyedHelper}var __toESM = (mod) => mod; +var require_g = __commonJS({ "g.js"(exports) { exports.g = 1; } }); +var import_g = __toESM(require_g()); +export const g = import_g.g; +`, + "/pkg/dist/index.js", + ); + expect(result).toEqual({ degrade: expect.stringContaining("bundled CommonJS modules") as string }); +}); diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index 8b065ccda..c4a12e998 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -245,6 +245,12 @@ function strictPrologue(stmts: readonly ts.Statement[]): boolean { return false; } +/** A cheap text probe: the helper's name, or its minified body's + * `(m={exports:{}}).exports,m` fragment. */ +export function mayBundleChunks(source: string): boolean { + return source.includes("__commonJS") || /\{\s*exports\s*:\s*\{\s*\}\s*\}\s*\)\s*\.exports\s*,/.test(source); +} + /** Splits a CommonJS bundle's __commonJS chunks into virtual modules (see * the header). Null: no chunks (or not a CommonJS bundle) — serve the file * unchanged. */ @@ -253,7 +259,7 @@ export function splitBundledChunks( filePath: string, ): BundledChunkSplit | { degrade: string } | null { const source = sf.text; - if (!source.includes("__commonJS")) return null; + if (!mayBundleChunks(source)) return null; // ES-module bundles have no require: their chunks keep the existing // treatment (the Bun factory recognizer, or the ESM interop refusal). if (!filePath.endsWith(".cjs") && ts.isExternalModule(sf)) return null; @@ -273,7 +279,6 @@ export function splitBundledChunks( } } const helperNames = [...top.keys()].filter((n) => helperBase(n) === "__commonJS"); - if (helperNames.length === 0) return null; // Every name a recognizer relies on is written nowhere and declared // once at the top level; `Object` itself is never rebound. @@ -293,6 +298,20 @@ export function splitBundledChunks( helperTokens(init.getText(sf)) === helperTokens("Object.getOwnPropertyNames") ); }; + if (helperNames.length === 0) { + // The helper's shape under another name: an identifier-minified bundle, + // whose other interop helpers lost their names as well. + for (const [name, t] of top) { + const init = ts.isVariableDeclaration(t.node) ? t.node.initializer : undefined; + if (init !== undefined && stable(name) && helperForm(init, sf, ownNames) !== null) + return { + degrade: + "its shipped JS is an identifier-minified bundle whose bundler-interop helpers " + + "(__commonJS and the rest) have lost the names the recognizers verify", + }; + } + return null; + } const forms = new Map(); for (const name of helperNames) { const t = top.get(name)!; diff --git a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts index fb591379b..1730e1153 100644 --- a/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts +++ b/packages/compiler/src/frontend/npm-static-rewrite-syntax.ts @@ -542,13 +542,14 @@ export function rewriteBundlerCjsSyntax( if (!filePath.endsWith(".cjs") && ts.isExternalModule(sf)) { const bundled = rewriteBundledFunctionImports(source, sf); if (bundled !== null) return bundled; - return source.includes("__toESM(") - ? { - degrade: - "its ES-module dist routes an external dependency through the __toESM " + - "bundler-interop helper, which cannot compile natively in ESM output", - } - : null; + if (!source.includes("__toESM(")) return null; + return { + degrade: source.includes("__commonJS") + ? "its ES-module bundle reaches bundled CommonJS modules through the __commonJS and " + + "__toESM bundler-interop helpers, which cannot compile natively in ESM output" + : "its ES-module dist routes an external dependency through the __toESM " + + "bundler-interop helper, which cannot compile natively in ESM output", + }; } // The __toESM interop pass (see the section header): wrapper call sites diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 40a989a0c..c6bb24ae5 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -2,7 +2,11 @@ import { Ts7SourceParser, type Ts7SourceKind } from "./ts7/source-parser.js"; import { Ts7Host, type Ts7ApiFactory, type Ts7HostOptions } from "./ts7/program-host.js"; import { rewriteBundlerCjsWithParser } from "./npm-static-rewrite-host.js"; import { isBundlerCjsCandidate } from "./npm-static-rewrite-syntax.js"; -import { splitBundledChunks, type BundledChunkSplit } from "./npm-static-bundled-chunks.js"; +import { + mayBundleChunks, + splitBundledChunks, + type BundledChunkSplit, +} from "./npm-static-bundled-chunks.js"; import * as syntax from "./npm-static-declaration-syntax.js"; import type { SourceFile } from "./ts7/ast-types.js"; @@ -69,7 +73,7 @@ export class FrontendServices { * chunks; a degrade reason: chunks the recognizer cannot move). */ splitCjsChunks(source: string, path: string): BundledChunkSplit | { degrade: string } | null { this.ensureOpen(); - if (!source.includes("__commonJS")) return null; + if (!mayBundleChunks(source)) return null; return splitBundledChunks(this.parse(path, source, "js"), path); } nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null { From eb998d4f4d253b93e8678d2f39d42c481e89bd64 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:18:13 -0700 Subject: [PATCH 16/85] Key class fields and methods by symbols from CommonJS key tables A JavaScript package often keeps its private symbols in one module, `module.exports = { kClosed: Symbol('closed'), ... }`, and its classes declare `[kClosed] = false` fields and `[kDispatch]()` methods with keys destructured from that table (undici's core/symbols.js). Those keys were not stable, so every such class was rejected, and with it every subclass in other modules ("extending classes not declared in the program"). A property of a JS module's effective top-level `module.exports = {...}` literal initialized with `Symbol()` or `Symbol.for()` is now a stable class key, as a module-scope const is, provided nothing before the table can run code (a require there could close a cycle and publish the empty initial exports object), no earlier property can run code, and nothing writes the property or replaces it through `exports`. The property's own declaration symbol is the identity every requirer shares, and the table's export global supplies the runtime key for dynamic lookups (a base method calling a subclass's symbol-keyed method). The checker types such keys as plain `symbol`, so it answers `this[k]` with the union of every symbol-keyed property of the class. Reads through table keys in JS files now take the field's own layout type. Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 6 +- .../src/frontend/lowering/class-prototypes.ts | 9 + .../compiler/src/frontend/lowering/lowerer.ts | 13 +- .../src/frontend/lowering/symbol-fields.ts | 223 ++++++++++++++++-- tests/corpus/cjs-symbol-key-table/main.js | 19 ++ .../corpus/cjs-symbol-key-table/package.json | 3 + tests/corpus/cjs-symbol-key-table/queue.js | 37 +++ tests/corpus/cjs-symbol-key-table/symbols.js | 11 + .../cjs-symbol-key-table/upper-queue.js | 12 + 9 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 tests/corpus/cjs-symbol-key-table/main.js create mode 100644 tests/corpus/cjs-symbol-key-table/package.json create mode 100644 tests/corpus/cjs-symbol-key-table/queue.js create mode 100644 tests/corpus/cjs-symbol-key-table/symbols.js create mode 100644 tests/corpus/cjs-symbol-key-table/upper-queue.js diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index a0ed496e1..1a3691f9c 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -2884,10 +2884,12 @@ export class ClassDynamicDispatch { for (const [symbol, name] of info.symbolMethods) symbolEntries.push([symbol, name]); for (const [symbol, name] of symbolEntries) { const declaration = lowerer.checker.valueDeclarationOf(symbol); + // Module-scope consts and CommonJS key-table properties + // (classSymbolKeyOfSymbol) both hold their key in a global. if ( declaration && - ts.isVariableDeclaration(declaration) && - ts.isIdentifier(declaration.name) + ((ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name)) || + ts.isPropertyAssignment(declaration)) ) { const global = lowerer.globalsBySymbol.get(symbol); if (global) diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index 403a4a3b4..e90a197a7 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -556,6 +556,15 @@ function prototypeProtectedKeys( const entries: [ts.Symbol, string][] = owner.symbolMethods ? [...owner.symbolMethods] : []; const entry = entries.find(([, field]) => field === name); const declaration = entry ? lowerer.checker.valueDeclarationOf(entry[0]) : undefined; + // A CommonJS key-table property holds its key in the table's global. + const tableKey = + declaration && ts.isPropertyAssignment(declaration) + ? lowerer.globalsBySymbol.get(entry![0]) + : undefined; + if (tableKey) { + keys.push({ kind: "varRef", localId: tableKey.id, type: tableKey.type, loc }); + continue; + } if ( !declaration || !ts.isVariableDeclaration(declaration) || diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d252af250..4ecb0176b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -124,7 +124,7 @@ import { } from "../tier-analysis.js"; import { DeferredModuleInitializers } from "./deferred-module-initializers.js"; import { finalizeClassMethodValues } from "./class-method-values.js"; -import type { ClassSymbolKey } from "./symbol-fields.js"; +import { type ClassSymbolKey, tableKeyFieldType } from "./symbol-fields.js"; import type { HttpClientFnBinding } from "./lower-server.js"; export type { WidthLift } from "./width-lift.js"; export { newFnCtx, type FnCtx } from "./function-context.js"; @@ -7958,6 +7958,17 @@ export class Lowerer { return this.checker.getUnknownType(); } } + // A symbol-keyed field read through a CommonJS key-table key reads as + // the field's layout type (tableKeyFieldType). + if (ts.isElementAccessExpression(node) && isJsSourceFile(node.getSourceFile())) { + const field = tableKeyFieldType(this, node); + if (field) { + if (field.kind === "f64") return this.checker.getNumberType(); + if (field.kind === "string") return this.checker.getStringType(); + if (field.kind === "bool") return this.checker.getBooleanType(); + return this.checker.getUnknownType(); + } + } // A checked JS method receives its actual call-site receiver. Its // declaration's inferred class type must not turn property reads back // into native field loads when the method is borrowed by another object. diff --git a/packages/compiler/src/frontend/lowering/symbol-fields.ts b/packages/compiler/src/frontend/lowering/symbol-fields.ts index f4f536af9..1aeadfde5 100644 --- a/packages/compiler/src/frontend/lowering/symbol-fields.ts +++ b/packages/compiler/src/frontend/lowering/symbol-fields.ts @@ -1,5 +1,10 @@ import * as ts from "../ts7/adapter.js"; -import { bindingEarlyUse7 } from "../program.js"; +import { + bindingEarlyUse7, + cjsExportDiscardReason, + isCjsExportTableLiteral, + isModuleExportsAccess, +} from "../program.js"; import type { Lowerer } from "./lowerer.js"; import type { IrType } from "../../ir/ir.js"; @@ -96,6 +101,11 @@ export function classSymbolKeyOfSymbol(lowerer: Lowerer, sym: ts.Symbol): ClassS cache.set(sym, null); const decls = lowerer.checker.declarationsOf(sym); const decl = lowerer.checker.valueDeclarationOf(sym); + if (decls.length === 1 && decl && ts.isPropertyAssignment(decl)) { + const key = cjsTableSymbolKeyOf(lowerer, sym, decl); + if (key) cache.set(sym, key); + return key; + } if (decls.length !== 1 || !decl || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null; const list = decl.parent; @@ -124,21 +134,9 @@ export function classSymbolKeyOfSymbol(lowerer: Lowerer, sym: ts.Symbol): ClassS cache.set(sym, alias); return alias; } - if (!init || !ts.isCallExpression(init) || init.questionDotToken) return null; - const callee = init.expression; - const registered = - ts.isPropertyAccessExpression(callee) && !callee.questionDotToken && callee.name.text === "for"; - const root = registered ? callee.expression : callee; - if (!ts.isIdentifier(root) || !lowerer.isStdlibGlobal(root, "Symbol")) return null; - const arg = - init.arguments.nodes.length === 0 - ? null - : init.arguments.nodes.length === 1 - ? init.arguments.nodes[0]! - : undefined; - if (arg === undefined || (registered && arg === null)) return null; - const description = arg === null ? "" : constantSymbolDescription(lowerer, arg); - if (description === null) return null; + const call = init ? literalSymbolCallOf(lowerer, init) : null; + if (!call) return null; + const { registered, description } = call; // Ordinary Symbol() is a fresh identity per evaluation, so only the // module-scope declaration above can name one fixed layout slot. const sf = decl.getSourceFile(); @@ -184,6 +182,17 @@ export function classSymbolKeyOfSymbol(lowerer: Lowerer, sym: ts.Symbol): ClassS ) !== null ) return null; + const key = symbolKeyOf(lowerer, sym, registered, description); + cache.set(sym, key); + return key; +} + +function symbolKeyOf( + lowerer: Lowerer, + sym: ts.Symbol, + registered: boolean, + description: string, +): ClassSymbolKey { // A registry name denotes one symbol even when several source bindings // initialize it. Intern those names into a representative checker symbol; // the layout table then has one identity-keyed domain for both forms. @@ -193,7 +202,7 @@ export function classSymbolKeyOfSymbol(lowerer: Lowerer, sym: ts.Symbol): ClassS if (existing) identity = existing; else lowerer.registeredClassSymbols.set(description, sym); } - const key: ClassSymbolKey = { + return { sym, identity, // Symbol slots are not string properties. The reserved prefix keeps @@ -201,8 +210,158 @@ export function classSymbolKeyOfSymbol(lowerer: Lowerer, sym: ts.Symbol): ClassS // Object.keys/JSON serialization and string-property writeback. fieldName: `${FIELD_PREFIX}Symbol(${description})`, }; - cache.set(sym, key); - return key; +} + +/** `Symbol()` or `Symbol.for()` on the standard global. */ +function literalSymbolCallOf( + lowerer: Lowerer, + init: ts.Expression, +): { registered: boolean; description: string } | null { + if (!ts.isCallExpression(init) || init.questionDotToken) return null; + const callee = init.expression; + const registered = + ts.isPropertyAccessExpression(callee) && !callee.questionDotToken && callee.name.text === "for"; + const root = registered ? callee.expression : callee; + if (!ts.isIdentifier(root) || !lowerer.isStdlibGlobal(root, "Symbol")) return null; + const arg = + init.arguments.nodes.length === 0 + ? null + : init.arguments.nodes.length === 1 + ? init.arguments.nodes[0]! + : undefined; + if (arg === undefined || (registered && arg === null)) return null; + const description = arg === null ? "" : constantSymbolDescription(lowerer, arg); + return description === null ? null : { registered, description }; +} + +/** A key table property: `kX: Symbol('x')` in a JS module's effective + * top-level `module.exports = { ... }` literal (undici's core/symbols.js). + * The module evaluates once, so the property names one symbol identity for + * every requirer, as a module-scope const does. Requirers can observe the + * table only after its assignment completes, provided nothing before it + * can run code (a require there could close a cycle and publish the + * initial empty exports object instead). Any write to the property, or + * replacement through `exports`/`module.exports`, keeps it unstable. */ +function cjsTableSymbolKeyOf( + lowerer: Lowerer, + sym: ts.Symbol, + decl: ts.PropertyAssignment, +): ClassSymbolKey | null { + const table = decl.parent; + if (!ts.isObjectLiteralExpression(table) || !ts.isIdentifier(decl.name)) return null; + const assign = table.parent; + if ( + !ts.isBinaryExpression(assign) || + assign.right !== table || + !isCjsExportTableLiteral(table) || + !ts.isExpressionStatement(assign.parent) + ) + return null; + const stmt = assign.parent; + const sf = stmt.getSourceFile(); + if (cjsExportDiscardReason(stmt) !== null) return null; + const call = literalSymbolCallOf(lowerer, decl.initializer); + if (!call) return null; + const inert = (expr: ts.Expression | undefined): boolean => { + if (!expr) return true; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + return ( + ts.isIdentifier(expr) || + ts.isArrowFunction(expr) || + ts.isFunctionExpression(expr) || + ts.isStringLiteralLike(expr) || + ts.isNumericLiteral(expr) || + expr.kind === ts.SyntaxKind.TrueKeyword || + expr.kind === ts.SyntaxKind.FalseKeyword || + expr.kind === ts.SyntaxKind.NullKeyword || + literalSymbolCallOf(lowerer, expr) !== null + ); + }; + const statements = sf.statements.nodes; + const prefixInert = statements + .slice(0, statements.indexOf(stmt)) + .every( + (statement) => + (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) || + ts.isFunctionDeclaration(statement) || + (ts.isVariableStatement(statement) && + statement.declarationList.declarations.nodes.every( + (d) => ts.isIdentifier(d.name) && inert(d.initializer), + )), + ); + if (!prefixInert) return null; + for (const property of table.properties.nodes) { + if (property === decl) break; + if (ts.isShorthandPropertyAssignment(property)) continue; + if ( + !ts.isPropertyAssignment(property) || + ts.isComputedPropertyName(property.name) || + !inert(property.initializer) + ) + return null; + } + // Requirers can reach the property through a checker-synthesized symbol + // (the export= type's member); the declaration's own symbol is the one + // identity every requirer and the table's export global share. + const canonical = lowerer.checker.getSymbolAtLocation(decl.name) ?? sym; + if (tablePropertyWritten(lowerer, new Set([sym, canonical]), decl.name.text, sf)) return null; + return { ...symbolKeyOf(lowerer, canonical, call.registered, call.description), sym }; +} + +function tablePropertyWritten( + lowerer: Lowerer, + symbols: ReadonlySet, + name: string, + home: ts.SourceFile, +): boolean { + let written = false; + const targets = (node: ts.Node): boolean => { + if (ts.isPropertyAccessExpression(node)) { + if (node.name.text !== name) return false; + const recv = node.expression; + if ( + node.getSourceFile() === home && + ((ts.isIdentifier(recv) && recv.text === "exports") || isModuleExportsAccess(recv)) + ) + return true; + const target = lowerer.checker.getSymbolAtLocation(node.name); + return target !== undefined && symbols.has(target); + } + if (ts.isElementAccessExpression(node)) { + const key = node.argumentExpression; + if (!ts.isStringLiteralLike(key) || key.text !== name) return false; + const target = lowerer.checker.getSymbolAtLocation(node); + return target !== undefined && symbols.has(target); + } + return false; + }; + for (const sf of lowerer.program.getSourceFiles()) { + if (written) break; + if (sf.isDeclarationFile || !sf.text.includes(name)) continue; + ts.walkPreorder(sf, (node) => { + if (written) return "stop"; + let target: ts.Node | undefined; + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + target = node.left; + else if (ts.isDeleteExpression(node)) target = node.expression; + else if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) + ) + target = node.operand; + if (target && targets(target)) { + written = true; + return "stop"; + } + return undefined; + }); + } + return written; } function bindingWritten(lowerer: Lowerer, sym: ts.Symbol, sf: ts.SourceFile): boolean { @@ -245,3 +404,29 @@ function bindingWritten(lowerer: Lowerer, sym: ts.Symbol, sf: ts.SourceFile): bo }); return written; } + +/** The layout type of a symbol-keyed field read through a CommonJS + * key-table key (`this[kCount]` with `kCount` from a `module.exports = { + * kCount: Symbol('count') }` table), or null. The checker types such keys + * as plain `symbol`, so it answers the read with the union of every + * symbol-keyed property of the class; the field's own type is the read's + * type. Keys declared as consts keep their unique-symbol checker types. */ +export function tableKeyFieldType( + lowerer: Lowerer, + node: ts.ElementAccessExpression, +): IrType | null { + const key = node.argumentExpression; + if (!ts.isIdentifier(key) && !ts.isPropertyAccessExpression(key)) return null; + const keyType = lowerer.checker.getTypeAtLocation(key); + if (!(keyType.flags & ts.TypeFlags.ESSymbol) || keyType.flags & ts.TypeFlags.UniqueESSymbol) + return null; + const resolved = classSymbolKeyOf(lowerer, key); + if (!resolved) return null; + const decl = lowerer.checker.valueDeclarationOf(resolved.identity); + if (!decl || !ts.isPropertyAssignment(decl)) return null; + const receiver = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(node.expression)); + if (receiver?.kind !== "object") return null; + const info = lowerer.classes.get(receiver.className); + const field = info?.symbolFields?.get(resolved.identity); + return field === undefined ? null : (info!.fields.get(field) ?? null); +} diff --git a/tests/corpus/cjs-symbol-key-table/main.js b/tests/corpus/cjs-symbol-key-table/main.js new file mode 100644 index 000000000..2c639bf6e --- /dev/null +++ b/tests/corpus/cjs-symbol-key-table/main.js @@ -0,0 +1,19 @@ +// Symbol-keyed class fields and methods whose keys come from a CommonJS +// key table in another module: field declarations, constructor and method +// assignments, a base method calling a subclass's symbol-keyed method, +// reads through the table from a third module, and inspect output. +'use strict'; + +const { inspect } = require('node:util'); +const UpperQueue = require('./upper-queue'); +const { kState, kSize, kName } = require('./symbols'); + +const q = new UpperQueue('jobs'); +console.log(q.push('a'), q.push('b'), q.push(3)); +console.log(q.state, q[kSize], q[kName]); +console.log(q.drain()); +console.log(q.state, q[kSize], q[kState]); +q[kName] = 'renamed'; +console.log(q.state); +console.log(inspect(q)); +console.log(Object.keys(q)); diff --git a/tests/corpus/cjs-symbol-key-table/package.json b/tests/corpus/cjs-symbol-key-table/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/cjs-symbol-key-table/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/cjs-symbol-key-table/queue.js b/tests/corpus/cjs-symbol-key-table/queue.js new file mode 100644 index 000000000..c5b7fdaf5 --- /dev/null +++ b/tests/corpus/cjs-symbol-key-table/queue.js @@ -0,0 +1,37 @@ +'use strict'; + +const { kState, kSize, kName, kRun } = require('./symbols'); + +class Queue { + // Field declarations keyed by table symbols. + [kState] = 'idle'; + + [kSize] = 0; + + constructor(name) { + this[kName] = name; + this.items = []; + } + + push(item) { + this.items.push(item); + this[kSize] = this.items.length; + return this[kSize]; + } + + drain() { + this[kState] = 'draining'; + // The subclass declares the symbol-keyed method this calls. + const out = this[kRun](this.items); + this.items = []; + this[kSize] = 0; + this[kState] = 'idle'; + return out; + } + + get state() { + return this[kName] + ' is ' + this[kState]; + } +} + +module.exports = Queue; diff --git a/tests/corpus/cjs-symbol-key-table/symbols.js b/tests/corpus/cjs-symbol-key-table/symbols.js new file mode 100644 index 000000000..1eca50273 --- /dev/null +++ b/tests/corpus/cjs-symbol-key-table/symbols.js @@ -0,0 +1,11 @@ +// The key table: one module exports every symbol its siblings use as +// class keys (undici's core/symbols.js). Each property names one symbol +// identity for all requirers. +'use strict'; + +module.exports = { + kState: Symbol('state'), + kSize: Symbol('size'), + kName: Symbol('name'), + kRun: Symbol('run'), +}; diff --git a/tests/corpus/cjs-symbol-key-table/upper-queue.js b/tests/corpus/cjs-symbol-key-table/upper-queue.js new file mode 100644 index 000000000..cebdb5b58 --- /dev/null +++ b/tests/corpus/cjs-symbol-key-table/upper-queue.js @@ -0,0 +1,12 @@ +'use strict'; + +const Queue = require('./queue'); +const symbols = require('./symbols'); + +class UpperQueue extends Queue { + [symbols.kRun](items) { + return items.map((item) => String(item).toUpperCase()).join(' '); + } +} + +module.exports = UpperQueue; From e2d4092b16dfdac1879ff01b5893efcc88871f9a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:18:18 -0700 Subject: [PATCH 17/85] Re-export classes through module.exports members that require them `module.exports.Agent = require('./lib/agent')`, where agent.js ends with `module.exports = Agent`, is the same alias plumbing as `module.exports.Agent = Agent`: importers resolve to the class declaration and the class registry applies, instead of storing a class value (which classes inheriting a runtime-provided constructor, such as EventEmitter subclasses, do not have). The statement still evaluates the required module at its position. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 5 +++- .../compiler/src/frontend/lowering/lowerer.ts | 24 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 36abb9130..6feccd5a5 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -6168,7 +6168,10 @@ function lowerCjsExportStatement( // storage — collectGlobals skipped it; importers re-resolve through // cjsMemberExportClassSymbol): the statement lowers to nothing. if (lowerer.cjsMemberExportClassSymbol(cjs.expr) !== null) { - return { kind: "block", body: [], loc }; + // An inline require still evaluates its module here. + const req = inlineProgramRequireOf(lowerer, cjs.value); + const init = req !== null ? lowerer.requireInitStmt(req.spec, req.call) : null; + return init ?? { kind: "block", body: [], loc }; } // The tsc VOID-INIT PREAMBLE (`exports.leaf = void 0;` with the real // `exports.leaf = leaf;` further down — every tsc-emitted dist file diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 4ecb0176b..aa05b734e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -294,6 +294,7 @@ import { import { prepareCjsModuleGraph, usesCjsTopLevelThis } from "./lower-node-module.js"; import { cjsNamespaceModule, + cjsWholeExportValueOf, inlineNamespaceRequireOf, inlineProgramRequireOf, } from "./cjs-inline-require.js"; @@ -3672,13 +3673,18 @@ export class Lowerer { ? d.parent : null; if (!assign || assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return null; - if (!ts.isIdentifier(assign.right)) return null; const stmt = assign.parent; if (!stmt || !ts.isExpressionStatement(stmt) || !ts.isSourceFile(stmt.parent)) return null; if (!isJsSourceFile(stmt.parent)) return null; const cjs = cjsExportAssignmentOf(stmt); if (cjs?.kind !== "member" || cjs.expr !== assign) return null; if (cjsExportDiscardReason(stmt) !== null) return null; + // `module.exports.C = require('./c')` over a module whose whole export + // is a class identifier: the same alias plumbing; the statement keeps + // the require's module evaluation at its position. + const required = this.memberExportRequiredClass(assign.right); + if (required !== undefined) return required; + if (!ts.isIdentifier(assign.right)) return null; let value = this.checker.getSymbolAtLocation(assign.right); if (!value) return null; if (value.flags & ts.SymbolFlags.Alias) value = this.checker.getAliasedSymbol(value); @@ -3686,6 +3692,22 @@ export class Lowerer { return isClass ? value : null; } + /** For an inline program `require("./c")`: the class symbol its module's + * kept `module.exports = ` names, or null when the + * whole export is something else. Undefined when `expr` is not such a + * require. */ + memberExportRequiredClass(expr: ts.Expression): ts.Symbol | null | undefined { + const req = inlineProgramRequireOf(this, expr); + if (req === null) return undefined; + const whole = cjsWholeExportValueOf(req.dep); + if (whole === null || !ts.isIdentifier(whole.rhs)) return null; + let value = this.checker.getSymbolAtLocation(whole.rhs); + if (!value) return null; + if (value.flags & ts.SymbolFlags.Alias) value = this.checker.getAliasedSymbol(value); + const isClass = this.checker.declarationsOf(value).some((decl) => ts.isClassDeclaration(decl)); + return isClass ? value : null; + } + /** The CommonJS JS module a DEFAULT-import binding's declaration loads * (`import d from "./lib.cjs"`), or null: Node's ESM-CJS interop binds * the default to module.exports — exactly a require binding — so those From c698667bf0fc10bd4dc4ba44340522ab7ea01944 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:18:25 -0700 Subject: [PATCH 18/85] Let TypeScript classes override methods of JavaScript base classes `class TracingAgent extends Agent { dispatch(opts: DispatchOptions, handler: DispatchHandler): boolean { ... super.dispatch(...) } }` over an npm-static JavaScript Agent was refused: the override's typed parameters had to match the base's untyped (checked-value) slots exactly. A TypeScript override of a JavaScript method now keeps the base's ABI. Each untyped base slot stays a checked value, and the override's typed parameter narrows from it at entry with a checked conversion; an untyped base return takes the override's value boxed; trailing base slots the override omits are ignored. Calls through base-typed receivers dispatch to the override, and calls on the subclass type see the same ABI. The corpus program builds the undici dispatcher shape as an npm package: an EventEmitter-rooted base, symbol-keyed fields from a key table, a computed accessor, subclasses across modules re-exported through require, and TypeScript subclasses overriding `dispatch`. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 5 +- .../src/frontend/lowering/lower-classes.ts | 50 ++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 15 +++++ tests/corpus/cjs-class-hierarchy-npm/main.ts | 58 +++++++++++++++++++ .../node_modules/dispatchkit/index.d.ts | 27 +++++++++ .../node_modules/dispatchkit/index.js | 12 ++++ .../node_modules/dispatchkit/lib/agent.js | 18 ++++++ .../node_modules/dispatchkit/lib/describe.js | 9 +++ .../dispatchkit/lib/dispatcher-base.js | 48 +++++++++++++++ .../dispatchkit/lib/dispatcher.js | 15 +++++ .../node_modules/dispatchkit/lib/pool.js | 20 +++++++ .../node_modules/dispatchkit/lib/symbols.js | 11 ++++ .../node_modules/dispatchkit/package.json | 6 ++ 13 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/cjs-class-hierarchy-npm/main.ts create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.d.ts create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/agent.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/describe.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher-base.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/pool.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/symbols.js create mode 100644 tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/package.json diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 9d9c9668c..82f9f6ba3 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -46,12 +46,15 @@ export type ParamMode = "required" | "omittable" | "rest" | "dynRest" | "argumen * otherwise. `bodyType` is present exactly for DEFAULTED program params: * the plain T the body sees after the prologue applies the default (see * declareParams). `callDefault` is the compile-time default value for a - * table-backed builtin's omittable slot. */ + * table-backed builtin's omittable slot. `entryNarrow` marks a dyn ABI slot + * inherited from a JavaScript base method whose TypeScript override declares + * a typed parameter: the prologue narrows the checked value to that type. */ export interface ParamShape { type: IrType; mode: ParamMode; bodyType?: IrType; callDefault?: IrExpr; + entryNarrow?: IrType; } export interface FnSig { diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 770c74840..8dd114a27 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -3345,6 +3345,56 @@ export function collectClassShapeInner( .map((p): ParamShape => ({ type: p.type, mode: "omittable" })), ); } + // A TypeScript override of a JavaScript method (`class T extends + // Agent` over an npm-static package) keeps the base's checked-value + // ABI: each untyped base slot stays dyn, and the override's typed + // parameter narrows from it at entry with a checked conversion. An + // untyped base return takes the override's value boxed. Calls on + // a statically typed receiver see the same ABI. + if ( + overridden && + !isJsSourceFile(member.getSourceFile()) && + overridden.declarer.decl !== null && + isJsSourceFile(overridden.declarer.decl.getSourceFile()) && + member.asteriskToken === undefined && + !overridden.sig.async && + !overridden.sig.gen && + shapes.length <= overridden.sig.params.length && + shapes.every((p, i) => { + const inherited = overridden.sig.params[i]!; + return ( + (p.mode === "required" || p.mode === "omittable") && + p.bodyType === undefined && + (inherited.mode === "required" || inherited.mode === "omittable") && + (typeEquals(p.type, inherited.type) || + (inherited.type.kind === "dyn" && lowerer.dynConvertible(p.type))) + ); + }) && + overridden.sig.params + .slice(shapes.length) + .every( + (p) => p.type.kind === "dyn" && (p.mode === "required" || p.mode === "omittable"), + ) + ) { + for (let i = 0; i < shapes.length; i++) { + const own = shapes[i]!; + if (overridden.sig.params[i]!.type.kind === "dyn" && own.type.kind !== "dyn") + shapes[i] = { type: DYN, mode: own.mode, entryNarrow: own.type }; + } + shapes.push( + ...overridden.sig.params + .slice(shapes.length) + .map((p): ParamShape => ({ type: p.type, mode: "omittable" })), + ); + if ( + overridden.sig.ret.kind === "dyn" && + ft.ret.kind !== "dyn" && + ft.ret.kind !== "void" && + lowerer.dynConvertible(ft.ret) + ) + ft.ret = DYN; + ft.params = shapes.map((shape) => shape.type); + } if ( overridden && member.asteriskToken === undefined && diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index aa05b734e..45c2f4f22 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -10892,6 +10892,21 @@ export class Lowerer { return; } const name = (decl.name as ts.Identifier).text; + if (shape.entryNarrow) { + // A typed override parameter over an inherited checked-value slot: + // the body local narrows from the dyn argument at entry. + const loc = locOf(decl); + const slot = this.declareHiddenLocal(name, shape.type); + params.push({ localId: slot.id, name, type: shape.type }); + const narrowed = this.coerceInto( + decl.name, + { kind: "varRef", localId: slot.id, type: shape.type, loc }, + shape.entryNarrow, + ); + const body = this.declareLocal(decl.name, name, shape.entryNarrow, true); + prologue.push({ kind: "varDecl", localId: body.id, init: narrowed, loc }); + return; + } if (shape.mode === "omittable" && shape.bodyType && decl.initializer) { const abi = shape.type; if (abi.kind === "dyn") { diff --git a/tests/corpus/cjs-class-hierarchy-npm/main.ts b/tests/corpus/cjs-class-hierarchy-npm/main.ts new file mode 100644 index 000000000..b709aa556 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/main.ts @@ -0,0 +1,58 @@ +// Class hierarchies across the CommonJS modules of an npm package (the +// undici dispatcher shape): an EventEmitter-rooted base in one module, +// symbol-keyed fields whose keys come from a shared export table, a +// computed accessor, subclasses in further modules re-exported through +// require bindings and inline requires, and TypeScript subclasses that +// override a JavaScript method with typed parameters and call super. +import { Agent, Dispatcher, DispatcherBase, Pool, describe, type Handler } from "dispatchkit"; + +class TracingAgent extends Agent { + readonly trace: string[] = []; + + constructor() { + super({ label: "tracing", prefix: "+" }); + } + + dispatch(path: string, handler: Handler): boolean { + this.trace.push(path); + return super.dispatch(path.toUpperCase(), handler); + } +} + +class LimitedPool extends Pool { + rejected = 0; + + dispatch(path: string, handler: Handler): boolean { + const ok = super.dispatch(path, handler); + if (!ok) this.rejected++; + return ok; + } +} + +const print: Handler = (line) => console.log("handled", line); + +function run(d: Dispatcher, paths: string[]): void { + for (const path of paths) console.log(path, "->", d.dispatch(path, print)); +} + +const plain = new Agent(); +run(plain, ["/a", "/b"]); +console.log(describe(plain), plain.kind, plain.prefix); + +const tracing = new TracingAgent(); +tracing.on("closed", (label: unknown, count: unknown) => console.log("closed event", label, count)); +run(tracing, ["/x", "/y/z"]); +console.log(tracing.trace.join(","), tracing.dispatched, tracing.closed); +tracing.close(); +tracing.close(); +console.log(describe(tracing), tracing.kind); +try { + tracing.dispatch("/late", print); +} catch (err) { + console.log("error", (err as Error).message); +} + +const pool = new LimitedPool(3); +run(pool, ["/1", "/long", "/2"]); +console.log(pool.rejected, pool.size, pool.dispatched, pool instanceof DispatcherBase); +console.log(describe(pool), pool.kind); diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.d.ts b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.d.ts new file mode 100644 index 000000000..bde4d4117 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.d.ts @@ -0,0 +1,27 @@ +import { EventEmitter } from "node:events"; + +export type Handler = (line: string) => void; + +export declare class Dispatcher extends EventEmitter { + dispatch(path: string, handler: Handler): boolean; + close(): void; +} + +export declare class DispatcherBase extends Dispatcher { + constructor(opts?: { label?: string }); + get closed(): boolean; + get dispatched(): number; + get kind(): string; +} + +export declare class Agent extends DispatcherBase { + constructor(opts?: { label?: string; prefix?: string }); + prefix: string; +} + +export declare class Pool extends Agent { + constructor(size: number); + size: number; +} + +export declare function describe(d: Dispatcher): string; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.js new file mode 100644 index 000000000..1f6cd7f85 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/index.js @@ -0,0 +1,12 @@ +'use strict'; + +// The undici index shape: classes re-exported through require bindings and +// inline requires of modules whose whole export is the class. +const Dispatcher = require('./lib/dispatcher'); +const { describe } = require('./lib/describe'); + +module.exports.Dispatcher = Dispatcher; +module.exports.DispatcherBase = require('./lib/dispatcher-base'); +module.exports.Agent = require('./lib/agent'); +module.exports.Pool = require('./lib/pool'); +module.exports.describe = describe; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/agent.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/agent.js new file mode 100644 index 000000000..95b2e02f2 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/agent.js @@ -0,0 +1,18 @@ +'use strict'; + +const DispatcherBase = require('./dispatcher-base'); +const { kCount, kDispatch, kLabel } = require('./symbols'); + +class Agent extends DispatcherBase { + constructor({ label = 'agent', prefix = '>' } = {}) { + super({ label }); + this.prefix = prefix; + } + + [kDispatch](path, handler) { + handler(this.prefix + ' ' + this[kLabel] + ' ' + path + ' #' + this[kCount]); + return true; + } +} + +module.exports = Agent; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/describe.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/describe.js new file mode 100644 index 000000000..7c0c3aef7 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/describe.js @@ -0,0 +1,9 @@ +'use strict'; + +const { kCount, kLabel } = require('./symbols'); + +function describe(d) { + return d[kLabel] + ': ' + d[kCount] + ' dispatched, ' + (d.closed ? 'closed' : 'open'); +} + +module.exports = { describe }; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher-base.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher-base.js new file mode 100644 index 000000000..091455592 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher-base.js @@ -0,0 +1,48 @@ +'use strict'; + +const Dispatcher = require('./dispatcher'); +const { kClosed, kDispatch, kCount, kLabel, kOnClose } = require('./symbols'); + +const KIND = 'kind'; + +class DispatcherBase extends Dispatcher { + // Symbol-keyed fields whose keys come from another module. + [kClosed] = false; + + [kCount] = 0; + + [kOnClose] = null; + + constructor(opts) { + super(); + this[kLabel] = (opts && opts.label) || 'base'; + } + + get closed() { + return this[kClosed]; + } + + get dispatched() { + return this[kCount]; + } + + // A computed accessor name. + get [KIND]() { + return 'dispatcher:' + this[kLabel]; + } + + close() { + if (this[kClosed]) return; + this[kClosed] = true; + this.emit('closed', this[kLabel], this[kCount]); + } + + dispatch(path, handler) { + if (this[kClosed]) throw new Error(this[kLabel] + ' is closed'); + this[kCount]++; + // Subclasses declare the symbol-keyed method this calls. + return this[kDispatch](path, handler); + } +} + +module.exports = DispatcherBase; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher.js new file mode 100644 index 000000000..510208c49 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/dispatcher.js @@ -0,0 +1,15 @@ +'use strict'; + +const EventEmitter = require('node:events'); + +class Dispatcher extends EventEmitter { + dispatch() { + throw new Error('not implemented'); + } + + close() { + throw new Error('not implemented'); + } +} + +module.exports = Dispatcher; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/pool.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/pool.js new file mode 100644 index 000000000..837239fbd --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/pool.js @@ -0,0 +1,20 @@ +'use strict'; + +const Agent = require('./agent'); +const { kCount, kDispatch, kLabel } = require('./symbols'); + +class Pool extends Agent { + constructor(size) { + super({ label: 'pool', prefix: '*' }); + this.size = size; + } + + // Overrides the symbol-keyed method Agent declares. + [kDispatch](path, handler) { + if (path.length > this.size) return false; + handler(this.prefix + ' ' + this[kLabel] + ' ' + path + ' #' + this[kCount]); + return true; + } +} + +module.exports = Pool; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/symbols.js b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/symbols.js new file mode 100644 index 000000000..a795c4178 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/lib/symbols.js @@ -0,0 +1,11 @@ +'use strict'; + +// Keys shared across modules through one export table (undici's +// core/symbols.js). Two keys deliberately share no description. +module.exports = { + kClosed: Symbol('closed'), + kDispatch: Symbol('dispatch'), + kCount: Symbol('count'), + kLabel: Symbol('label'), + kOnClose: Symbol('on close'), +}; diff --git a/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/package.json b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/package.json new file mode 100644 index 000000000..e187b1539 --- /dev/null +++ b/tests/corpus/cjs-class-hierarchy-npm/node_modules/dispatchkit/package.json @@ -0,0 +1,6 @@ +{ + "name": "dispatchkit", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +} From 796b1a974068e5e072cc321c10dc905dd156989b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:18:25 -0700 Subject: [PATCH 19/85] Read arguments as a value in strict CommonJS functions A CommonJS function with parameters that reads `arguments` was refused because sloppy-mode `arguments` aliases the parameters. Strict code (a 'use strict' prologue on the file or an enclosing function, or a class body) and functions with non-simple parameter lists (defaults or destructuring) get Node's unmapped object, whose elements never alias the parameters, so they now take the hidden arguments array the way ES module functions and class methods already do. undici's `webidl.argumentLengthCheck(arguments, 1, 'getCookies')` compiles. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 35 ++++++++++++++- tests/corpus/cjs-strict-arguments.cjs | 43 +++++++++++++++++++ tests/corpus/cjs-unmapped-arguments.cjs | 28 ++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/cjs-strict-arguments.cjs create mode 100644 tests/corpus/cjs-unmapped-arguments.cjs diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 82f9f6ba3..f9906240c 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -735,7 +735,11 @@ function appendArgumentsParameter( const classMethod = ts.isMethodDeclaration(node) && (ts.isClassDeclaration(node.parent) || ts.isClassExpression(node.parent)); - if (!classMethod && !isNodeEsmFile(node.getSourceFile(), lowerer.program)) { + if ( + !classMethod && + !isNodeEsmFile(node.getSourceFile(), lowerer.program) && + !unmappedArguments(node) + ) { lowerer.unsupported( "SC1090", node, @@ -746,6 +750,35 @@ function appendArgumentsParameter( shapes.push({ type: DYN, mode }); } +/** Whether `arguments` inside `node` is Node's unmapped object: strict code + * (a 'use strict' prologue on the file or an enclosing function, or a class + * body) or a non-simple parameter list (defaults, rest, destructuring). Its + * elements then never alias the parameters, which is the ES module case. */ +function unmappedArguments(node: ts.SignatureDeclaration): boolean { + if ( + node.parameters.nodes.some( + (p) => + p.initializer !== undefined || p.dotDotDotToken !== undefined || !ts.isIdentifier(p.name), + ) + ) + return true; + const useStrict = (statements: readonly ts.Statement[]): boolean => { + for (const statement of statements) { + if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) + return false; + if (statement.expression.text === "use strict") return true; + } + return false; + }; + for (let n: ts.Node | undefined = node; n; n = n.parent) { + if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return true; + if (ts.isFunctionLike(n) && n.body && ts.isBlock(n.body) && useStrict(n.body.statements.nodes)) + return true; + if (ts.isSourceFile(n)) return useStrict(n.statements.nodes); + } + return false; +} + /** Signature checks + param shapes + IR func type for any lambda-like * node. The func type's params are the ABI types: optional/default params * become `T | undefined`, and typed rest becomes one packed-array slot. diff --git a/tests/corpus/cjs-strict-arguments.cjs b/tests/corpus/cjs-strict-arguments.cjs new file mode 100644 index 000000000..78af27f0d --- /dev/null +++ b/tests/corpus/cjs-strict-arguments.cjs @@ -0,0 +1,43 @@ +// `arguments` as a value in CommonJS functions with parameters where Node +// gives the unmapped object: a 'use strict' file prologue (undici's +// webidl.argumentLengthCheck(arguments, 1, name) idiom) and non-simple +// parameter lists. The object's elements never alias the parameters. +'use strict'; + +function argumentLengthCheck(args, min, ctx) { + if (args.length < min) { + throw new TypeError(`${ctx}: ${min} argument${min === 1 ? '' : 's'} required, but only ${args.length} present.`); + } +} + +function getCookies(headers) { + argumentLengthCheck(arguments, 1, 'getCookies'); + return 'cookies of ' + headers; +} + +function setCookie(headers, cookie) { + argumentLengthCheck(arguments, 2, 'setCookie'); + headers = headers + '!'; + return headers + ' ' + cookie + ' ' + arguments[0]; +} + +function count(first, second) { + const args = arguments; + let total = 0; + for (let i = 0; i < args.length; i++) total += args[i]; + return first + ':' + second + ':' + total; +} + +console.log(getCookies('h1')); +console.log(setCookie('h2', 'c=1')); +try { + getCookies(); +} catch (err) { + console.log(err.name, err.message); +} +try { + setCookie('only'); +} catch (err) { + console.log(err.message); +} +console.log(count(1, 2), count(1, 2, 3, 4), count(5)); diff --git a/tests/corpus/cjs-unmapped-arguments.cjs b/tests/corpus/cjs-unmapped-arguments.cjs new file mode 100644 index 000000000..ab4a8f3cb --- /dev/null +++ b/tests/corpus/cjs-unmapped-arguments.cjs @@ -0,0 +1,28 @@ +// Sloppy-mode CommonJS functions whose parameter lists are not simple +// (defaults, destructuring) get the unmapped `arguments` object: +// writing a parameter never shows through it. +function withDefault(a, b = 2) { + a = 'changed'; + return [a, b, arguments.length, arguments[0]].join(' '); +} + +function withPattern({ x }, y) { + y = 'changed'; + return [x, y, arguments.length, arguments[1]].join(' '); +} + +function passAlong(first, { y } = {}) { + first = 'changed'; + return collect(arguments) + ' / ' + y + ' ' + first; +} + +function collect(args) { + const out = []; + for (let i = 0; i < args.length; i++) out.push(String(args[i])); + return out.join(','); +} + +console.log(withDefault('orig')); +console.log(withDefault('orig', 5, 6)); +console.log(withPattern({ x: 1 }, 'orig')); +console.log(passAlong('a', { y: 'b' }, 'c')); From 405f643742afb50214b58298717a146a14719d42 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:43:47 -0700 Subject: [PATCH 20/85] Judge bundled packages by the files their imports can load Interop the rewrite cannot finish now refuses its package only when the compiled program can load that file: the module order closed over every literal require() and import() and, in package JavaScript, every import and export declaration (a computed specifier keeps every file loadable). A package reached through a leaf subpath and types from its main entry stays static. Auto mode also no longer refuses a whitespace/syntax minified bundle whose interop helpers keep their names: the recognizers judge it instead. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/npm-static.ts | 31 +++++++++-- packages/compiler/src/frontend/pipeline.ts | 10 ++++ packages/compiler/src/frontend/program.ts | 51 +++++++++++++++++++ .../npm/cases/2562-subpath-leaf/main.ts | 10 ++++ .../npm/node_modules/gtsubpath/dist/data.d.ts | 2 + .../npm/node_modules/gtsubpath/dist/data.js | 6 +++ .../npm/node_modules/gtsubpath/dist/detect.js | 11 ++++ .../node_modules/gtsubpath/dist/index.d.ts | 6 +++ .../npm/node_modules/gtsubpath/dist/index.js | 5 ++ .../npm/node_modules/gtsubpath/package.json | 10 ++++ tests/harness/npm-cases.ts | 4 +- tests/harness/npm-static.test.ts | 26 ++++++++-- 12 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/npm/cases/2562-subpath-leaf/main.ts create mode 100644 tests/fixtures/npm/node_modules/gtsubpath/dist/data.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtsubpath/dist/data.js create mode 100644 tests/fixtures/npm/node_modules/gtsubpath/dist/detect.js create mode 100644 tests/fixtures/npm/node_modules/gtsubpath/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtsubpath/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtsubpath/package.json diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index f377628d6..7dffd05f7 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -48,6 +48,7 @@ export function setNpmStaticPackages>(packages: T): v declarationOverloads = new Map(); declarationProperties = new Map(); offenders.clear(); + fileDegrades.clear(); rewriteCache.clear(); liveExportFiles.clear(); clearVirtualSources(); @@ -98,6 +99,21 @@ export function npmStaticOffenders(): ReadonlyMap { return offenders; } +/** Files whose bundler interop the rewrite could not finish this attempt, + * with their package and reason. A file refuses its package only when + * the compiled program can load it (npmStaticLoadableFiles): a package is + * judged by what its imported subpaths reach, not by every file it ships. */ +const fileDegrades = new Map(); + +export function npmStaticFileDegrades(): ReadonlyMap { + return fileDegrades; +} + +function reportFileDegrade(path: string, pkg: string, reason: string): void { + const key = path.split("\\").join("/"); + if (!fileDegrades.has(key)) fileDegrades.set(key, { pkg, reason }); +} + /** The DefinitelyTyped name mangling ("@scope/pkg" → "scope__pkg") — the * @types twin that must hide alongside the package's own declarations. */ function mangledTypesName(name: string): string { @@ -385,7 +401,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow // CommonJS module text. const split = original === null ? null : services.splitCjsChunks(original, path); if (split !== null && "degrade" in split) { - reportNpmStaticOffender(target.pkg, split.degrade); + reportFileDegrade(path, target.pkg, split.degrade); original = null; } else if (split !== null) { for (const chunk of split.chunks) setVirtualSource(chunk.path, chunk.source); @@ -416,7 +432,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow (live) => liveExportFiles.add(live.split("\\").join("/")), ); if (answer !== null && typeof answer === "object") { - reportNpmStaticOffender(target.pkg, answer.degrade); + reportFileDegrade(path, target.pkg, answer.degrade); } else { rewritten = answer ?? @@ -464,6 +480,14 @@ function looksUnminified(source: string): boolean { return source.length / lines.length <= 200; } +/** A whitespace/syntax-minified bundler dist whose interop helpers keep + * their names (`var __toESM=…,__toCommonJS=mod=>…`): the recognizers + * verify those shapes and refuse the package precisely when they cannot, + * so the minification heuristic need not refuse it up front. */ +function namedBundlerHelpers(source: string): boolean { + return /\b__(?:toCommonJS|commonJS|toESM)\d*\s*=\s*(?:\(|[A-Za-z_$][\w$]*\s*=>)/.test(source); +} + function hasTransformMarkers(source: string): boolean { return TRANSFORM_MARKERS.some((m) => source.includes(m)); } @@ -486,7 +510,8 @@ export function npmStaticIneligibleReason( if (jsEntry === null) return "no runtime JS entry resolves"; const source = trackedReadFile(jsEntry); if (source === null) return `its runtime entry ${jsEntry} cannot be read`; - if (!looksUnminified(source)) return "its shipped JS looks minified"; + if (!looksUnminified(source) && !namedBundlerHelpers(source)) + return "its shipped JS looks minified"; if (hasTransformMarkers(source)) { return "its shipped JS carries build-transform markers (bundled/transpiled dist)"; } diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 024c69d98..b11fa2b63 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -10,11 +10,13 @@ import { checkPreflight, isNodeTypesPath, locOf, + npmStaticLoadableFiles, requiresOf, resolveNpmImport, type LoadResult, } from "./program.js"; import { + npmStaticFileDegrades, npmStaticIneligibleReason, npmStaticOffenders, npmStaticPackageOfPath, @@ -458,6 +460,14 @@ function loadFrontend( const typecheckRefused = new Set(); while (effective.size > 0) { const reasons = new Map(npmStaticOffenders()); + // Bundler interop the rewrite could not finish refuses its package + // only where the compiled program can load the file. + if (npmStaticFileDegrades().size > 0) { + const loadable = npmStaticLoadableFiles(load.program, load.moduleOrder); + for (const [file, { pkg, reason }] of npmStaticFileDegrades()) { + if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) reasons.set(pkg, reason); + } + } for (const d of preflight) { const pkg = npmStaticPackageOfPath(d.loc.file); if (pkg !== null && !reasons.has(pkg)) diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 12cdfd76c..fa02d15f7 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6023,6 +6023,57 @@ export function orderedImportsOf( return out; } +/** The files a compiled program can load: the module order closed over + * every literal require() and import() call it contains (deferred and + * nested ones included) and, for opted-in package JavaScript, over every + * import and export declaration as well. A package file outside this set + * is reachable only through types, so whatever it spells cannot affect + * the compiled program. Null when a reachable file loads a computed + * specifier: then any file may load. */ +export function npmStaticLoadableFiles( + program: ts.Program, + moduleOrder: readonly ts.SourceFile[], +): Set | null { + const seen = new Set(); + const work: ts.SourceFile[] = []; + const add = (sf: ts.SourceFile | null | undefined): void => { + if (sf === null || sf === undefined || seen.has(sf) || sf.isDeclarationFile) return; + seen.add(sf); + work.push(sf); + }; + for (const sf of moduleOrder) add(sf); + let computed = false; + while (work.length > 0 && !computed) { + const sf = work.pop()!; + const packageJs = npmStaticPackageOfPath(sf.fileName) !== null && isJsSourceFileName(sf.fileName); + const follow = (spec: string, kind: "import" | "require"): void => { + if (canonicalBuiltinModule(spec) !== null) return; + add(resolveImport7(program, sf, spec, kind) ?? npmStaticDepSf7(program, sf, spec, kind)); + }; + ts.walkPreorder(sf, (n) => { + if (computed) return "stop"; + if (ts.isCallExpression(n)) { + const isImport = n.expression.kind === ts.SyntaxKind.ImportKeyword; + const isRequire = ts.isIdentifier(n.expression) && n.expression.text === "require"; + if (!isImport && !isRequire) return undefined; + const arg = n.arguments.nodes[0]; + if (arg !== undefined && ts.isStringLiteralLike(arg)) + follow(arg.text, isImport ? "import" : "require"); + else if (packageJs || isImport) computed = true; + } else if ( + packageJs && + (ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) && + n.moduleSpecifier !== undefined && + ts.isStringLiteral(n.moduleSpecifier) + ) { + follow(n.moduleSpecifier.text, "import"); + } + return undefined; + }); + } + return computed ? null : new Set([...seen].map((sf) => sf.fileName.split("\\").join("/"))); +} + /** The program source file a bare specifier reaches when it names an * opted-in --npm-static package (the shipped-JS entry preflight admitted * as a module edge), else null. No offender reporting here — preflight diff --git a/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts b/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts new file mode 100644 index 000000000..b34359cc7 --- /dev/null +++ b/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts @@ -0,0 +1,10 @@ +// The program reaches gtsubpath only through its leaf data module (and a +// type from its main entry). The main entry reaches a file whose bundler +// interop the recognizer cannot verify, but no compiled code can load it, +// so the package compiles: a package is judged by what its imported +// subpaths reach. +import type { Manager } from "gtsubpath"; +import { managers } from "gtsubpath/dist/data.js"; + +const names: Manager[] = managers.filter((m) => m.lockfile.endsWith(".yaml")); +console.log(managers.length, names.map((m) => m.name).join(",")); diff --git a/tests/fixtures/npm/node_modules/gtsubpath/dist/data.d.ts b/tests/fixtures/npm/node_modules/gtsubpath/dist/data.d.ts new file mode 100644 index 000000000..356c8bbc9 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtsubpath/dist/data.d.ts @@ -0,0 +1,2 @@ +import type { Manager } from "./index.js"; +export declare const managers: Manager[]; diff --git a/tests/fixtures/npm/node_modules/gtsubpath/dist/data.js b/tests/fixtures/npm/node_modules/gtsubpath/dist/data.js new file mode 100644 index 000000000..918a296a8 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtsubpath/dist/data.js @@ -0,0 +1,6 @@ +"use strict"; +exports.managers = [ + { name: "npm", lockfile: "package-lock.json" }, + { name: "pnpm", lockfile: "pnpm-lock.yaml" }, + { name: "yarn", lockfile: "yarn.lock" }, +]; diff --git a/tests/fixtures/npm/node_modules/gtsubpath/dist/detect.js b/tests/fixtures/npm/node_modules/gtsubpath/dist/detect.js new file mode 100644 index 000000000..d129041fe --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtsubpath/dist/detect.js @@ -0,0 +1,11 @@ +// A hand-rolled __toESM the recognizer cannot verify (see gtdrift). +"use strict"; +var __toESM = (mod) => { + var ns = { default: mod }; + for (var key in mod) ns[key] = mod[key]; + return ns; +}; +var import_gtcore = __toESM(require("gtcore")); +exports.detect = function detect(files) { + return import_gtcore.default.label(files.length); +}; diff --git a/tests/fixtures/npm/node_modules/gtsubpath/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtsubpath/dist/index.d.ts new file mode 100644 index 000000000..24b05d86d --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtsubpath/dist/index.d.ts @@ -0,0 +1,6 @@ +export interface Manager { + name: string; + lockfile: string; +} +export declare const managers: Manager[]; +export declare function detect(files: string[]): string; diff --git a/tests/fixtures/npm/node_modules/gtsubpath/dist/index.js b/tests/fixtures/npm/node_modules/gtsubpath/dist/index.js new file mode 100644 index 000000000..a330d28c0 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtsubpath/dist/index.js @@ -0,0 +1,5 @@ +"use strict"; +const detect = require("./detect.js"); +const data = require("./data.js"); +exports.detect = detect.detect; +exports.managers = data.managers; diff --git a/tests/fixtures/npm/node_modules/gtsubpath/package.json b/tests/fixtures/npm/node_modules/gtsubpath/package.json new file mode 100644 index 000000000..f785cb154 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtsubpath/package.json @@ -0,0 +1,10 @@ +{ + "name": "gtsubpath", + "version": "1.0.0", + "description": "a package whose main entry reaches a file with bundler interop the recognizer cannot verify, beside a leaf data module the program imports by subpath", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "gtcore": "1.0.0" + } +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 8562ac24f..4b3b9fdee 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,12 +19,12 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469 and 2556-2561 are the --npm-static bundler-emitted-CJS + // 2465-2469 and 2556-2562 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[6-9]|256[01])-[^/]+\/main\.ts$/.test(entry)) + .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-2])-[^/]+\/main\.ts$/.test(entry)) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index b908f0bd9..a8df98c52 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,7 +76,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2561) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2562) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ]; for (const f of inputs) hash.update(f).update(readFileSync(f)); @@ -1405,8 +1405,11 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { runBinary("node", [entry]), runBinary(binary, []), ]); - nativeRes.stderr = comparableStderr(nativeRes.stderr); - expect(nativeRes).toEqual(nodeRes); + // Node's stderr carries its type-stripping warning; the programs write + // only to stdout. + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(comparableStderr(nativeRes.stderr).toString("utf8")).toBe(""); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); expect(nativeRes.exitCode).toBe(0); } @@ -1423,6 +1426,14 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 180_000); + // A whitespace/syntax-minified bundle whose helpers keep their names is + // judged by the recognizers, not refused up front as minified. + test("--npm-static=auto opts a minified bundle with named helpers in", () => { + const entry = join(fixturesRoot, "npm/cases/2558-bundled-chunks/main.ts"); + const { coverage } = analyze(entry, { npmStatic: "auto" }); + expect(coverage.npmStatic).toContainEqual({ package: "gtchunkws", status: "static" }); + }, 120_000); + test.each(["2559-cjs-cycle", "2560-cjs-cycle-partner-first"])( "the esbuild CommonJS cycle %s compiles statically and byte-matches Node", async (name) => { @@ -1446,6 +1457,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 120_000); + // 2562: a file with unverifiable interop that no compiled code can load + // (the program imports a leaf data module and only a type from the + // main entry) leaves its package static. + test("a package is judged by the files its imported subpaths can load", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2562-subpath-leaf/main.ts"), [ + "gtsubpath", + ]); + }, 180_000); + test("published type-only declarations coexist with a static runtime bundle", async () => { const entry = join(fixturesRoot, "npm/cases/2469-bundle-offender/main.ts"); const { coverage } = analyze(entry, { npmStatic: ["gtghost"] }); From bab3b9800165ccc0d378031d269bb1b4d50f3178 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:50:37 -0700 Subject: [PATCH 21/85] Record order baselines for the new npm cases Co-Authored-By: Claude Opus 5.5 --- .../fixtures/npm/cases/2558-bundled-chunks/main.ts.json | 6 ++++++ .../tests/fixtures/npm/cases/2559-cjs-cycle/main.ts.json | 6 ++++++ .../npm/cases/2560-cjs-cycle-partner-first/main.ts.json | 6 ++++++ .../npm/cases/2561-cjs-cycle-early-read/main.ts.json | 6 ++++++ .../tests/fixtures/npm/cases/2562-subpath-leaf/main.ts.json | 6 ++++++ 5 files changed, 30 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts.json new file mode 100644 index 000000000..cc5bc69c8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2558-bundled-chunks/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts.json new file mode 100644 index 000000000..382eef7b4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2559-cjs-cycle/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts.json new file mode 100644 index 000000000..f609f128e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2560-cjs-cycle-partner-first/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts.json new file mode 100644 index 000000000..be3b2af16 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2561-cjs-cycle-early-read/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts.json new file mode 100644 index 000000000..68cde3de1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2562-subpath-leaf/main.ts" + ], + "diags": [] +} From 506b7994baf64a4b039bd534a1ac7bd82b4a4d39 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 12:59:49 -0700 Subject: [PATCH 22/85] Type chunk records once so the compiler compiles itself Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/npm-static-bundled-chunks.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index c4a12e998..837e7c498 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -230,6 +230,16 @@ function copyableInitializer(init: ts.Expression | undefined): boolean { return depth > 0 && ts.isIdentifier(walk) && walk.text === "Object"; } +/** One recognized chunk declaration. */ +interface Chunk { + name: string; + stmt: ts.VariableStatement; + params: string[]; + body: ts.Block; + path: string; + spec: string; +} + interface TopLevel { stmt: ts.Statement; /** The declarator (or function declaration) itself. */ @@ -322,14 +332,6 @@ export function splitBundledChunks( } // The chunk declarations: `var NAME = HELPER(factory);`, one declarator. - interface Chunk { - name: string; - stmt: ts.VariableStatement; - params: string[]; - body: ts.Block; - path: string; - spec: string; - } const chunks = new Map(); const base = filePath.replace(/^.*[\\/]/, ""); for (const [name, t] of top) { @@ -470,12 +472,8 @@ function applyEdits(source: string, from: number, to: number, edits: Edit[]): st /** The virtual module text for one chunk, or a degrade reason. */ function chunkSource( sf: ts.SourceFile, - chunk: { - name: string; - params: string[]; - body: ts.Block; - }, - chunks: ReadonlyMap, + chunk: Chunk, + chunks: ReadonlyMap, top: ReadonlyMap, stable: (name: string) => boolean, callEdits: readonly Edit[], From 40ca92fa13c56e2693da73c4904ce80debf616e9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:01:47 -0700 Subject: [PATCH 23/85] Format Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/npm-static-bundled-chunks.test.ts | 4 +++- packages/compiler/src/frontend/npm-static-bundled-chunks.ts | 5 ++++- packages/compiler/src/frontend/pipeline.ts | 3 ++- packages/compiler/src/frontend/program.ts | 3 ++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts index 3ef430102..82926d2b8 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -178,5 +178,7 @@ export const g = import_g.g; `, "/pkg/dist/index.js", ); - expect(result).toEqual({ degrade: expect.stringContaining("bundled CommonJS modules") as string }); + expect(result).toEqual({ + degrade: expect.stringContaining("bundled CommonJS modules") as string, + }); }); diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index 837e7c498..cdb9dff64 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -258,7 +258,10 @@ function strictPrologue(stmts: readonly ts.Statement[]): boolean { /** A cheap text probe: the helper's name, or its minified body's * `(m={exports:{}}).exports,m` fragment. */ export function mayBundleChunks(source: string): boolean { - return source.includes("__commonJS") || /\{\s*exports\s*:\s*\{\s*\}\s*\}\s*\)\s*\.exports\s*,/.test(source); + return ( + source.includes("__commonJS") || + /\{\s*exports\s*:\s*\{\s*\}\s*\}\s*\)\s*\.exports\s*,/.test(source) + ); } /** Splits a CommonJS bundle's __commonJS chunks into virtual modules (see diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index b11fa2b63..676b2bb3e 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -465,7 +465,8 @@ function loadFrontend( if (npmStaticFileDegrades().size > 0) { const loadable = npmStaticLoadableFiles(load.program, load.moduleOrder); for (const [file, { pkg, reason }] of npmStaticFileDegrades()) { - if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) reasons.set(pkg, reason); + if (!reasons.has(pkg) && (loadable === null || loadable.has(file))) + reasons.set(pkg, reason); } } for (const d of preflight) { diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index fa02d15f7..908a587ea 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6045,7 +6045,8 @@ export function npmStaticLoadableFiles( let computed = false; while (work.length > 0 && !computed) { const sf = work.pop()!; - const packageJs = npmStaticPackageOfPath(sf.fileName) !== null && isJsSourceFileName(sf.fileName); + const packageJs = + npmStaticPackageOfPath(sf.fileName) !== null && isJsSourceFileName(sf.fileName); const follow = (spec: string, kind: "import" | "require"): void => { if (canonicalBuiltinModule(spec) !== null) return; add(resolveImport7(program, sf, spec, kind) ?? npmStaticDepSf7(program, sf, spec, kind)); From 2eb05d6e4f355437b1330ef7c86abe45a99c7917 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:43:24 -0700 Subject: [PATCH 24/85] Record TypeScript order baselines for the class hierarchy corpus programs Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/cjs-class-hierarchy-npm/main.ts.json | 6 ++++++ .../tests/corpus/cjs-strict-arguments.cjs.json | 6 ++++++ .../tests/corpus/cjs-symbol-key-table/main.js.json | 9 +++++++++ .../tests/corpus/cjs-unmapped-arguments.cjs.json | 6 ++++++ 4 files changed, 27 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-class-hierarchy-npm/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-strict-arguments.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-table/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-unmapped-arguments.cjs.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-class-hierarchy-npm/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-class-hierarchy-npm/main.ts.json new file mode 100644 index 000000000..f44d1965c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-class-hierarchy-npm/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/cjs-class-hierarchy-npm/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-strict-arguments.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-strict-arguments.cjs.json new file mode 100644 index 000000000..4e52c7e57 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-strict-arguments.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/cjs-strict-arguments.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-table/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-table/main.js.json new file mode 100644 index 000000000..3975cce3a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-table/main.js.json @@ -0,0 +1,9 @@ +{ + "order": [ + "/tests/corpus/cjs-symbol-key-table/symbols.js", + "/tests/corpus/cjs-symbol-key-table/queue.js", + "/tests/corpus/cjs-symbol-key-table/upper-queue.js", + "/tests/corpus/cjs-symbol-key-table/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-unmapped-arguments.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-unmapped-arguments.cjs.json new file mode 100644 index 000000000..bff6dd797 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-unmapped-arguments.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/cjs-unmapped-arguments.cjs" + ], + "diags": [] +} From a953203d3668f65f36f216f904581dee7da30138 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:33:28 -0700 Subject: [PATCH 25/85] Give key-table symbols that share a description their own slots undici keeps two key tables that both hold a `Symbol('close')`, and MockClient inherits a slot under one while declaring a method under the other. Symbol slots were named by their printable spelling, so the two collided ("distinct symbol keys sharing the printable name"). A key-table key whose description an earlier symbol identity already holds now takes an ordinal in its internal slot name; inspect drops the ordinal and prints Node's spelling for both. Const keys keep their plain names, so programs that compiled before are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 3 +++ .../src/frontend/lowering/symbol-fields.ts | 16 ++++++++++--- .../cjs-symbol-key-collisions/client.js | 16 +++++++++++++ .../cjs-symbol-key-collisions/core-symbols.js | 6 +++++ .../corpus/cjs-symbol-key-collisions/main.js | 11 +++++++++ .../cjs-symbol-key-collisions/mock-client.js | 24 +++++++++++++++++++ .../cjs-symbol-key-collisions/mock-symbols.js | 7 ++++++ .../cjs-symbol-key-collisions/package.json | 3 +++ 8 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 tests/corpus/cjs-symbol-key-collisions/client.js create mode 100644 tests/corpus/cjs-symbol-key-collisions/core-symbols.js create mode 100644 tests/corpus/cjs-symbol-key-collisions/main.js create mode 100644 tests/corpus/cjs-symbol-key-collisions/mock-client.js create mode 100644 tests/corpus/cjs-symbol-key-collisions/mock-symbols.js create mode 100644 tests/corpus/cjs-symbol-key-collisions/package.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 45c2f4f22..c7e8155ed 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1955,6 +1955,9 @@ export class Lowerer { * their symbol/node keys; no module-level cache should retain a pass. */ readonly classSymbolKeys = new Map(); readonly registeredClassSymbols = new Map(); + /** Symbol-key identities by description, in resolution order (the slot + * ordinals of key-table keys — symbolKeyOf). */ + readonly symbolFieldIdentities = new Map(); readonly httpClientFnBindings = new Map(); streamPropMutatedSyms: Set | null = null; requestInitPropMutatedSyms: Set | null = null; diff --git a/packages/compiler/src/frontend/lowering/symbol-fields.ts b/packages/compiler/src/frontend/lowering/symbol-fields.ts index 1aeadfde5..8dd236349 100644 --- a/packages/compiler/src/frontend/lowering/symbol-fields.ts +++ b/packages/compiler/src/frontend/lowering/symbol-fields.ts @@ -44,7 +44,7 @@ function constantSymbolDescription( } export function symbolFieldDisplayName(field: string): string { - return field.slice(FIELD_PREFIX.length); + return field.slice(FIELD_PREFIX.length).replace(/^\d+:/, ""); } /** CopyDataProperties includes symbols, unlike the string-keyed view used @@ -192,6 +192,7 @@ function symbolKeyOf( sym: ts.Symbol, registered: boolean, description: string, + table = false, ): ClassSymbolKey { // A registry name denotes one symbol even when several source bindings // initialize it. Intern those names into a representative checker symbol; @@ -202,13 +203,22 @@ function symbolKeyOf( if (existing) identity = existing; else lowerer.registeredClassSymbols.set(description, sym); } + // Distinct identities can share a description (undici's core and mock + // key tables both hold a `Symbol('close')`, and MockClient inherits one + // and declares the other). A key-table key whose description an earlier + // identity already holds takes an ordinal that keeps its slot apart; the + // printable name drops it. Const keys keep their plain names. + let identities = lowerer.symbolFieldIdentities.get(description); + if (!identities) lowerer.symbolFieldIdentities.set(description, (identities = [])); + let ordinal = identities.indexOf(identity); + if (ordinal < 0) ordinal = identities.push(identity) - 1; return { sym, identity, // Symbol slots are not string properties. The reserved prefix keeps // them out of the native class's string-keyed dynamic view, including // Object.keys/JSON serialization and string-property writeback. - fieldName: `${FIELD_PREFIX}Symbol(${description})`, + fieldName: `${FIELD_PREFIX}${table && ordinal > 0 ? `${ordinal}:` : ""}Symbol(${description})`, }; } @@ -305,7 +315,7 @@ function cjsTableSymbolKeyOf( // identity every requirer and the table's export global share. const canonical = lowerer.checker.getSymbolAtLocation(decl.name) ?? sym; if (tablePropertyWritten(lowerer, new Set([sym, canonical]), decl.name.text, sf)) return null; - return { ...symbolKeyOf(lowerer, canonical, call.registered, call.description), sym }; + return { ...symbolKeyOf(lowerer, canonical, call.registered, call.description, true), sym }; } function tablePropertyWritten( diff --git a/tests/corpus/cjs-symbol-key-collisions/client.js b/tests/corpus/cjs-symbol-key-collisions/client.js new file mode 100644 index 000000000..aaa9dbb5d --- /dev/null +++ b/tests/corpus/cjs-symbol-key-collisions/client.js @@ -0,0 +1,16 @@ +'use strict'; + +const { kClose, kUrl } = require('./core-symbols'); + +class Client { + constructor(url) { + this[kUrl] = url; + this[kClose] = 'core close slot'; + } + + close() { + return 'closing ' + this[kUrl] + ' (' + this[kClose] + ')'; + } +} + +module.exports = Client; diff --git a/tests/corpus/cjs-symbol-key-collisions/core-symbols.js b/tests/corpus/cjs-symbol-key-collisions/core-symbols.js new file mode 100644 index 000000000..e42309aa3 --- /dev/null +++ b/tests/corpus/cjs-symbol-key-collisions/core-symbols.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + kClose: Symbol('close'), + kUrl: Symbol('url'), +}; diff --git a/tests/corpus/cjs-symbol-key-collisions/main.js b/tests/corpus/cjs-symbol-key-collisions/main.js new file mode 100644 index 000000000..9f66616b4 --- /dev/null +++ b/tests/corpus/cjs-symbol-key-collisions/main.js @@ -0,0 +1,11 @@ +// Distinct symbols from two CommonJS key tables that share one description: +// each keeps its own slot, and inspect prints both under Node's spelling. +'use strict'; + +const { inspect } = require('node:util'); +const MockClient = require('./mock-client'); + +const m = new MockClient('http://a'); +console.log(m.shutdown()); +console.log(m.shutdown()); +console.log(inspect(m)); diff --git a/tests/corpus/cjs-symbol-key-collisions/mock-client.js b/tests/corpus/cjs-symbol-key-collisions/mock-client.js new file mode 100644 index 000000000..17113c4bb --- /dev/null +++ b/tests/corpus/cjs-symbol-key-collisions/mock-client.js @@ -0,0 +1,24 @@ +'use strict'; + +const Client = require('./client'); +const { kClose, kCalls } = require('./mock-symbols'); + +// Inherits the core `Symbol('close')` slot and declares a method under the +// mock table's distinct `Symbol('close')` (undici's MockClient shape). +class MockClient extends Client { + constructor(url) { + super(url); + this[kCalls] = ''; + } + + [kClose]() { + this[kCalls] = this[kCalls] + '*'; + return 'mock close ' + this[kCalls]; + } + + shutdown() { + return this[kClose]() + ' then ' + this.close(); + } +} + +module.exports = MockClient; diff --git a/tests/corpus/cjs-symbol-key-collisions/mock-symbols.js b/tests/corpus/cjs-symbol-key-collisions/mock-symbols.js new file mode 100644 index 000000000..9726a8444 --- /dev/null +++ b/tests/corpus/cjs-symbol-key-collisions/mock-symbols.js @@ -0,0 +1,7 @@ +'use strict'; + +// A second table reuses a description the first already holds. +module.exports = { + kClose: Symbol('close'), + kCalls: Symbol('calls'), +}; diff --git a/tests/corpus/cjs-symbol-key-collisions/package.json b/tests/corpus/cjs-symbol-key-collisions/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/cjs-symbol-key-collisions/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} From 7b1556e0f6c976a5ca1063b32cd1f9493329681a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:33:29 -0700 Subject: [PATCH 26/85] Compile function-local classes whose private names are used only on this Each evaluation of a class declared inside a function creates fresh private names, while the compiled class shares one layout across evaluations, so any private member refused the class. undici's webidl createIterator declares such a class (FastIterableIterator) at module load. The two agree when every private name is used only on `this` inside the class's own instance members (`this.#x`, `#x in this`), never inside a nested class or non-arrow function, and the class has no static members: each instance then meets only the methods of its own evaluation. Those classes now compile; everything else keeps the refusal. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 51 ++++++++++++++++++- tests/corpus/function-local-private-fields.js | 48 +++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/function-local-private-fields.js diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8dd114a27..30f30f69e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -5442,6 +5442,54 @@ function namedEvaluationName(expr: ts.ClassLikeDeclaration): string { return ""; } +/** Each evaluation of a function-local class creates fresh private names, + * while the compiled class shares one layout across evaluations. They agree + * when every private name is used only on `this` inside the class's own + * instance members (`this.#x`, `#x in this`), outside nested classes and + * non-arrow functions, and the class has no static members that could hand + * instances across evaluations: each instance then meets only the methods + * of its own evaluation (undici's webidl FastIterableIterator). */ +function privateNamesOnlyOnThis(expr: ts.ClassLikeDeclaration): boolean { + let ok = true; + for (const member of expr.members.nodes) { + if (ts.getModifiers(member)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) return false; + ts.walkPreorder(member, (node) => { + if (!ok) return "stop"; + // A nested class or non-arrow function rebinds `this`: any private + // name inside it disqualifies. + if ( + node !== member && + (ts.isClassDeclaration(node) || + ts.isClassExpression(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node)) + ) { + ts.walkPreorder(node, (inner) => { + if (ts.isPrivateIdentifier(inner)) ok = false; + return ok ? undefined : "stop"; + }); + return "skip"; + } + if (ts.isPrivateIdentifier(node)) { + const parent = node.parent; + const onThis = + parent === member || + (ts.isPropertyAccessExpression(parent) && + parent.name === node && + parent.expression.kind === ts.SyntaxKind.ThisKeyword) || + (ts.isBinaryExpression(parent) && + parent.left === node && + parent.operatorToken.kind === ts.SyntaxKind.InKeyword && + parent.right.kind === ts.SyntaxKind.ThisKeyword); + if (!onThis) ok = false; + } + return undefined; + }); + if (!ok) return false; + } + return true; +} + /** Collect a class expression once per enclosing specialization. Function-local * evaluations allocate fresh constructor objects; top-level expressions use * immortal templates. Static initialization remains limited to whole top-level @@ -5479,7 +5527,8 @@ export function lowerClassExpressionInfo( } if ( local && - expr.members.nodes.some((member) => member.name && ts.isPrivateIdentifier(member.name)) + expr.members.nodes.some((member) => member.name && ts.isPrivateIdentifier(member.name)) && + !privateNamesOnlyOnThis(expr) ) { lowerer.unsupported("SC1090", expr, "function-local classes with private brands"); } diff --git a/tests/corpus/function-local-private-fields.js b/tests/corpus/function-local-private-fields.js new file mode 100644 index 000000000..c8c999659 --- /dev/null +++ b/tests/corpus/function-local-private-fields.js @@ -0,0 +1,48 @@ +// A class declared inside a function with private fields used only on +// `this` (undici's webidl createIterator): each evaluation's instances meet +// only their own methods, so one compiled layout serves every evaluation. +'use strict'; + +function createIterator(name, entries) { + class FastIterableIterator { + #target; + #kind; + #index; + + constructor(target, kind) { + this.#target = target; + this.#kind = kind; + this.#index = 0; + } + + next() { + if (typeof this !== 'object' || this === null || !(#target in this)) { + throw new TypeError(`'next' called on an object that does not implement interface ${name} Iterator.`); + } + const values = entries(this.#target); + if (this.#index >= values.length) return { value: undefined, done: true }; + const pair = values[this.#index]; + this.#index = this.#index + 1; + if (this.#kind === 'key') return { value: pair[0], done: false }; + if (this.#kind === 'value') return { value: pair[1], done: false }; + return { value: pair[0] + '=' + pair[1], done: false }; + } + } + return function (target, kind) { + return new FastIterableIterator(target, kind); + }; +} + +const headers = createIterator('Headers', (t) => t.pairs); +const params = createIterator('Params', (t) => t.list); + +function drain(it) { + const out = []; + for (let step = it.next(); !step.done; step = it.next()) out.push(step.value); + return out.join(','); +} + +const target = { pairs: [['a', '1'], ['b', '2']] }; +console.log(drain(headers(target, 'key'))); +console.log(drain(headers(target, 'value'))); +console.log(drain(params({ list: [['x', 'y']] }, 'key+value'))); From 3c76868fca21917fd76eeac05b75bb12549a4c03 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:33:29 -0700 Subject: [PATCH 27/85] Pass TypeScript option literals to open JavaScript option parameters `new Agent({ keepAliveTimeout: 10 })` against undici's `constructor({ factory = defaultFactory, maxOrigins = Infinity, ...options } = {})` failed with SC2002: the literal was laid out against the record the checker infers for the pattern, which lacks the properties the rest element collects. When a TypeScript object literal flows into a checked-value slot whose contextual type is a JavaScript destructuring pattern with a rest element (or that pattern's `= {}` default), and the literal names a property the type does not declare, the literal is built as the plain object the JavaScript side receives. Every other literal keeps its previous lowering. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 48 ++++++++++++++++++- tests/corpus/cjs-open-options-npm/main.ts | 12 +++++ .../node_modules/optkit/index.d.ts | 11 +++++ .../node_modules/optkit/index.js | 21 ++++++++ .../node_modules/optkit/package.json | 6 +++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/cjs-open-options-npm/main.ts create mode 100644 tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts create mode 100644 tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js create mode 100644 tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c7e8155ed..2efe14160 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2112,6 +2112,51 @@ export class Lowerer { return fallback ?? null; } + /** A TypeScript object literal handed to a JavaScript checked-value + * parameter whose inferred type is open (`constructor({ factory, ...options + * } = {})` — the rest element admits any property): the literal names a + * property the contextual JS type does not declare, so it has no record + * layout there and is built as the plain object the JS side receives. */ + private excessJsContextualProperty( + literal: ts.ObjectLiteralExpression, + contextual: readonly ts.Type[], + ): boolean { + const objects = contextual.filter( + (part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)), + ); + if (objects.length === 0) return false; + // Only a destructuring pattern with a rest element is open; other + // inferred JS types keep their record lowering. + const fromJs = objects.every((part) => { + const sym = part.getSymbol(); + return ( + sym !== undefined && + this.checker.declarationsOf(sym).some((d) => { + if (!isJsSourceFile(d.getSourceFile())) return false; + // The pattern itself, or the `= {}` default of a pattern parameter. + const pattern = + ts.isObjectLiteralExpression(d) && + d.parent !== undefined && + ts.isParameter(d.parent) && + d.parent.initializer === d + ? d.parent.name + : d; + return ( + ts.isObjectBindingPattern(pattern) && + pattern.elements.nodes.some((element) => element.dotDotDotToken !== undefined) + ); + }) + ); + }); + if (!fromJs) return false; + return literal.properties.nodes.some((prop) => { + if (!prop.name || !(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) + return false; + const name = prop.name.text; + return objects.every((part) => !this.checker.getPropertyOfType(part, name)); + }); + } + /** The index value of a keyed read of a record's index signature that * can answer undefined for a missing key while its checker type does not * admit undefined, or null. A literal key naming a declared field is a @@ -9615,7 +9660,8 @@ export class Lowerer { builtinOption("SpawnOptions") || builtinOption("UrlObject") || builtinOption("URLFormatOptions") || - !this.mapTypeOf(this.typeOf(x)) + !this.mapTypeOf(this.typeOf(x)) || + this.excessJsContextualProperty(x, parts) ) { return lowerDynObjectLiteral(this, x); } diff --git a/tests/corpus/cjs-open-options-npm/main.ts b/tests/corpus/cjs-open-options-npm/main.ts new file mode 100644 index 000000000..9fd133155 --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/main.ts @@ -0,0 +1,12 @@ +// TypeScript object literals passed to a JavaScript constructor whose +// options parameter is a destructuring pattern with a rest element: the +// literal's extra properties reach the rest object. +import { Agent } from "optkit"; + +console.log(new Agent().describe("a")); +console.log(new Agent({ keepAliveTimeout: 10 }).describe("b")); +console.log(new Agent({ maxOrigins: 2, keepAliveTimeout: 5, pipelining: 1 }).describe("c")); +console.log(new Agent({ factory: (origin) => "client(" + origin + ")" }).describe("d")); + +class TracingAgent extends Agent {} +console.log(new TracingAgent({ pipelining: 3 }).describe("e")); diff --git a/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts new file mode 100644 index 000000000..643b92d84 --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts @@ -0,0 +1,11 @@ +export interface Options { + factory?: (origin: string) => string; + maxOrigins?: number; + keepAliveTimeout?: number; + pipelining?: number; +} + +export declare class Agent { + constructor(opts?: Options); + describe(origin: string): string; +} diff --git a/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js new file mode 100644 index 000000000..0034fc442 --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js @@ -0,0 +1,21 @@ +'use strict'; + +function defaultFactory(origin) { + return 'pool(' + origin + ')'; +} + +// undici's Agent constructor shape: named options with defaults, and a rest +// element collecting every other option for the per-origin dispatchers. +class Agent { + constructor({ factory = defaultFactory, maxOrigins = Infinity, ...options } = {}) { + this.factory = factory; + this.maxOrigins = maxOrigins; + this.options = options; + } + + describe(origin) { + return this.factory(origin) + ' max=' + this.maxOrigins + ' ' + JSON.stringify(this.options); + } +} + +module.exports = { Agent }; diff --git a/tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json b/tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json new file mode 100644 index 000000000..9552d73ee --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json @@ -0,0 +1,6 @@ +{ + "name": "optkit", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +} From cadcaae85a929a6cc1fbaa91da69d711a9d34b9b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:33:29 -0700 Subject: [PATCH 28/85] Refuse prototype reflection of members without a lowering at run time Reflecting a class prototype (the checked view's prototype helper) lowers each member's value and key from emitReachable. A member whose generic method instance or computed key had no lowering threw a PoisonError out of ClassDynamicDispatch and crashed the compiler (seen once undici's classes compile). The walk now contains those errors per member, drops the member's diagnostic, and refuses the reflection at run time like its other unsupported cases. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/class-prototypes.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index e90a197a7..9d7264b0f 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -22,7 +22,7 @@ import { type ClassInfo, } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; -import { newFnCtx } from "./lowerer.js"; +import { PoisonError, newFnCtx } from "./lowerer.js"; import { classMethodValue } from "./class-method-values.js"; /** Data added to a top-level class prototype has shared identity and remains @@ -376,14 +376,37 @@ export function reflectClassPrototype( const method = ts.isMethodDeclaration(member) ? name : `${ts.isGetAccessor(member) ? "get" : "set"}:${name}`; - const value = classMethodValue(lowerer, info.decl, info, method, loc); + // A member whose value or key has no lowering refuses the reflection + // at run time; its own diagnostic must not escape this walk (the + // walk runs from emitReachable, after statement fences are placed). + let before = lowerer.diags.length; + let value: IrExpr | null; + try { + value = classMethodValue(lowerer, info.decl, info, method, loc); + } catch (error) { + if (!(error instanceof PoisonError)) throw error; + lowerer.diags.splice(before); + return refuse( + "prototype reflection with a member that has no lowering is not supported yet", + ); + } if (!value || !lowerer.dynConvertible(value.type)) return refuse( "prototype reflection with a member that cannot cross a checked value boundary is not supported yet", ); - const key: IrExpr = ts.isComputedPropertyName(member.name) - ? lowerer.lowerExpr(member.name.expression) - : { kind: "strLit", value: member.name.text, type: STRING, loc }; + let key: IrExpr; + before = lowerer.diags.length; + try { + key = ts.isComputedPropertyName(member.name) + ? lowerer.lowerExpr(member.name.expression) + : { kind: "strLit", value: member.name.text, type: STRING, loc }; + } catch (error) { + if (!(error instanceof PoisonError)) throw error; + lowerer.diags.splice(before); + return refuse( + "prototype reflection with a computed member key that has no lowering is not supported yet", + ); + } initialization.then.push( define( key, From afdb7706c1ef35f1371b57c8f929c55467e7cc9e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:43:30 -0700 Subject: [PATCH 29/85] Record TypeScript order baselines for the undici class-shape corpus programs Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/cjs-open-options-npm/main.ts.json | 6 ++++++ .../corpus/cjs-symbol-key-collisions/main.js.json | 10 ++++++++++ .../tests/corpus/function-local-private-fields.js.json | 6 ++++++ 3 files changed, 22 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-open-options-npm/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-collisions/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-fields.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-open-options-npm/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-open-options-npm/main.ts.json new file mode 100644 index 000000000..3c3043a09 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-open-options-npm/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/cjs-open-options-npm/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-collisions/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-collisions/main.js.json new file mode 100644 index 000000000..205da46f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-symbol-key-collisions/main.js.json @@ -0,0 +1,10 @@ +{ + "order": [ + "/tests/corpus/cjs-symbol-key-collisions/core-symbols.js", + "/tests/corpus/cjs-symbol-key-collisions/client.js", + "/tests/corpus/cjs-symbol-key-collisions/mock-symbols.js", + "/tests/corpus/cjs-symbol-key-collisions/mock-client.js", + "/tests/corpus/cjs-symbol-key-collisions/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-fields.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-fields.js.json new file mode 100644 index 000000000..d63dfd6a6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-fields.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-local-private-fields.js" + ], + "diags": [] +} From d204ef71d627632c31fc73c195e2679466d31eda Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 13:56:00 -0700 Subject: [PATCH 30/85] Keep the private-brand diagnostics on shapes that still refuse Function-local classes whose private names are used only on `this` now compile, so the two diagnostics fixtures test a brand check on another object instead. Co-Authored-By: Claude Opus 5.5 --- tests/diagnostics/local-class-boundaries.ts | 2 +- tests/diagnostics/local-class-type-query-brand.ts | 6 +++++- tests/harness/__snapshots__/local-class-boundaries.ts.txt | 6 +++--- .../__snapshots__/local-class-type-query-brand.ts.txt | 8 ++++---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/diagnostics/local-class-boundaries.ts b/tests/diagnostics/local-class-boundaries.ts index a82dfb869..2c43e9900 100644 --- a/tests/diagnostics/local-class-boundaries.ts +++ b/tests/diagnostics/local-class-boundaries.ts @@ -1,4 +1,4 @@ -function privateBrand() { return class { #value = 1; read(): number { return this.#value; } }; } +function privateBrand() { return class { #value = 1; has(other: object): boolean { return #value in other; } }; } function selfName() { return class Local { copy() { return new Local(); } }; } class Base {} function derived() { return class extends Base {}; } diff --git a/tests/diagnostics/local-class-type-query-brand.ts b/tests/diagnostics/local-class-type-query-brand.ts index 566304627..aeb905cad 100644 --- a/tests/diagnostics/local-class-type-query-brand.ts +++ b/tests/diagnostics/local-class-type-query-brand.ts @@ -1,11 +1,15 @@ // The same speculative element-type query, reaching a function-local class -// with a private brand through the function's returned array. +// with a private brand through the function's returned array. The brand is +// tested on another object, so the evaluations' private names must differ. function make() { class Box { #v = 1; read(): number { return this.#v; } + same(other: object): boolean { + return #v in other; + } } return [new Box()]; } diff --git a/tests/harness/__snapshots__/local-class-boundaries.ts.txt b/tests/harness/__snapshots__/local-class-boundaries.ts.txt index 04dbc336c..6706a1278 100644 --- a/tests/harness/__snapshots__/local-class-boundaries.ts.txt +++ b/tests/harness/__snapshots__/local-class-boundaries.ts.txt @@ -1,12 +1,12 @@ local-class-boundaries.ts:1:34 - error SC1090: function-local classes with private brands are not supported yet - 1 | function privateBrand() { return class { #value = 1; read(): number { return this.#value; } }; } - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 1 | function privateBrand() { return class { #value = 1; has(other: object): boolean { return #value in other; } }; } + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 2 | function selfName() { return class Local { copy() { return new Local(); } }; } local-class-boundaries.ts:2:64 - error SC1090: named self references inside function-local classes are not supported yet - 1 | function privateBrand() { return class { #value = 1; read(): number { return this.#value; } }; } + 1 | function privateBrand() { return class { #value = 1; has(other: object): boolean { return #value in other; } }; } 2 | function selfName() { return class Local { copy() { return new Local(); } }; } | ^~~~~ 3 | class Base {} diff --git a/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt b/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt index 6db3418a8..ea877dec3 100644 --- a/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt +++ b/tests/harness/__snapshots__/local-class-type-query-brand.ts.txt @@ -1,6 +1,6 @@ -local-class-type-query-brand.ts:4:3 - error SC1090: function-local classes with private brands are not supported yet +local-class-type-query-brand.ts:5:3 - error SC1090: function-local classes with private brands are not supported yet - 3 | function make() { - 4 | class Box { + 4 | function make() { + 5 | class Box { | ^~~~~~~~~~~ - 5 | #v = 1; \ No newline at end of file + 6 | #v = 1; \ No newline at end of file From 7f836d276ded8a9dd64e12baf976d6e41c6ed5fe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 14:32:28 -0700 Subject: [PATCH 31/85] Answer private brand checks on checked receivers `#target in this` inside a JavaScript method reads the actual call-site receiver, a checked value, so the brand check fenced at run time (undici's FastIterableIterator, and the corpus program that pins it, which coverage counted as one deferred site). The brand is present exactly when the value is a class instance at or below the declarer: a typed reference boxed under the declarer or a descendant answers true, and one boxed under an ancestor answers with an instanceof test. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 09407f71f..a8d47dc59 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -13682,6 +13682,49 @@ function lowerPrivateIn( ); } } + // A checked value (`#x in this` inside a JavaScript method, whose + // receiver is the actual call-site value): the brand is present exactly + // when the value is a class instance at or below the declarer. A typed + // reference carries the class it was boxed under: the declarer or a + // descendant answers true; an ancestor answers per instance. + if (recv.type.kind === "dyn" && recv.kind === "varRef") { + const typedRefIs = (className: string): IrExpr => ({ + kind: "libCall", + fn: "dyn.typedRefIs", + args: [ + recv, + { kind: "strLit", value: typeKey({ kind: "object", className }), type: STRING, loc }, + ], + type: BOOL, + loc, + }); + const or = (left: IrExpr | null, right: IrExpr): IrExpr => + left === null ? right : { kind: "logical", op: "||", left, right, type: BOOL, loc }; + let test: IrExpr | null = null; + const below = (c: ClassInfo): void => { + test = or(test, typedRefIs(c.def.name)); + for (const sub of c.subclasses) below(sub); + }; + below(info); + for (let base = info.base; base; base = base.base) { + const baseType: IrType = { kind: "object", className: base.def.name }; + test = or(test, { + kind: "logical", + op: "&&", + left: typedRefIs(base.def.name), + right: { + kind: "instanceOf", + value: { kind: "dynCheck", value: recv, type: baseType, loc }, + className: declName, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }); + } + return test!; + } if (recv.type.kind !== "object") { lowerer.unsupported( "SC1090", From 493e876328bb0da89d7c3f578ed54e83667bd1c2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 14:48:19 -0700 Subject: [PATCH 32/85] Keep brand checks on TypeScript unknown receivers refused The checked-receiver brand check was meant for JavaScript methods, whose `this` is the actual call-site value. It also accepted TypeScript `unknown` receivers, which the private-members diagnostics fixture pins as refused (narrow first). It now applies to JavaScript sources only. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lower-exprs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index a8d47dc59..745e6efca 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -13682,12 +13682,12 @@ function lowerPrivateIn( ); } } - // A checked value (`#x in this` inside a JavaScript method, whose + // A checked value in JavaScript (`#x in this` inside a method, whose // receiver is the actual call-site value): the brand is present exactly // when the value is a class instance at or below the declarer. A typed // reference carries the class it was boxed under: the declarer or a // descendant answers true; an ancestor answers per instance. - if (recv.type.kind === "dyn" && recv.kind === "varRef") { + if (recv.type.kind === "dyn" && recv.kind === "varRef" && isJsSourceFile(expr.getSourceFile())) { const typedRefIs = (className: string): IrExpr => ({ kind: "libCall", fn: "dyn.typedRefIs", From 0e02fc28ca8ce822c19e25be8442a71268b1508f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 16:59:05 -0500 Subject: [PATCH 33/85] Accept mock.fn() without an original node:test changes, matching Node 24.22: - mock.fn() and t.mock.fn() without an original return undefined and record the arguments of each call, whatever the callers pass. The original and the initial implementation are a function that returns undefined, so restore() brings that function back. - mock.fn() with an explicit function type records F's parameters when F's return type is void or includes undefined. A return type without undefined, such as mock.fn<() => number>(), throws SC2020. mock.fn()'s default type, (...args: any[]) => undefined, maps to the checked-dynamic rest ABI used for program-declared (...args: unknown[]) functions: the calls log records the argument array, and the mock's wrapper spreads it into the implementation. A function whose fixed parameters are all unknown or any, or that has none, now converts to a checked-dynamic rest signature without fixed parameters, such as (...args: unknown[]) => void: each call passes the arguments in order, undefined for the ones the caller left out. This is how () => {} reaches mockImplementation and mockImplementationOnce, and it applies to any such slot in TypeScript programs, which threw SC1090 before. The mock-fn-default fixture and corpus program 5816 compare with Node, plain and with SCRIPTC_SAN=1. --- .../limitations/builtin-apis/test-mocks.mdx | 2 +- .../null-and-optional-values.mdx | 2 +- .../generated/node-v24-backlog.json | 1 + .../generated/node-v24-internal.json | 2 + internal/compatibility/static-support.json | 2 +- .../lowering/coercions/function-adapters.ts | 58 +++++++++++++- .../src/frontend/lowering/lower-mock.ts | 75 ++++++++++++++++--- packages/compiler/src/frontend/mock-shapes.ts | 19 +++-- packages/compiler/src/frontend/type-mapper.ts | 22 +++++- .../5816-fixed-into-unknown-rest.ts.json | 6 ++ tests/corpus/5816-fixed-into-unknown-rest.ts | 39 ++++++++++ tests/diagnostics/maybe-promise-fences.ts | 5 -- tests/diagnostics/mock-fences.ts | 12 ++- .../node-test/cases/mock-fn-default.ts | 72 ++++++++++++++++++ .../__snapshots__/maybe-promise-fences.ts.txt | 9 +-- .../harness/__snapshots__/mock-fences.ts.txt | 60 +++++++-------- .../5816-fixed-into-unknown-rest.ts.json | 1 + 17 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5816-fixed-into-unknown-rest.ts.json create mode 100644 tests/corpus/5816-fixed-into-unknown-rest.ts create mode 100644 tests/fixtures/node-test/cases/mock-fn-default.ts create mode 100644 tests/harness/corpus-costs/5816-fixed-into-unknown-rest.ts.json diff --git a/docs/content/limitations/builtin-apis/test-mocks.mdx b/docs/content/limitations/builtin-apis/test-mocks.mdx index a0c63cd98..6c52c5639 100644 --- a/docs/content/limitations/builtin-apis/test-mocks.mdx +++ b/docs/content/limitations/builtin-apis/test-mocks.mdx @@ -1,2 +1,2 @@ -- **Test mocks:** `mock.fn`, `mock.method`, `mock.restoreAll` and `mock.reset` compile, on the module's `mock` and on `t.mock`. `mock.method` mocks a function-valued field of a record, such as an object literal or an interface-typed object. Class instances, builtin objects, and object literals whose methods use `this` throw `SC2020`. Calls through the object record it as `this`, including calls of a method read off the object and called on its own, where Node records `undefined`. `object.method === mock` is `false`, because the field holds a wrapper of the mock. `mock.fn()` without an original, `mock.getter`, `mock.setter`, `mock.property`, `mock.module` and mock options throw `SC2020`. +- **Test mocks:** `mock.fn`, `mock.method`, `mock.restoreAll` and `mock.reset` compile, on the module's `mock` and on `t.mock`. `mock.method` mocks a function-valued field of a record, such as an object literal or an interface-typed object. Class instances, builtin objects, and object literals whose methods use `this` throw `SC2020`. Calls through the object record it as `this`, including calls of a method read off the object and called on its own, where Node records `undefined`. `object.method === mock` is `false`, because the field holds a wrapper of the mock. `mock.fn()` without an original returns `undefined` and records the arguments of each call, as in Node; with a function type whose return type does not include `undefined`, such as `mock.fn<() => number>()`, it throws `SC2020`. `mock.getter`, `mock.setter`, `mock.property`, `mock.module` and mock options throw `SC2020`. - **Mock timers:** `mock.timers.enable`, `tick`, `runAll`, `reset`, `setTime` and `[Symbol.dispose]()` compile, on the module's `mock` and on `t.mock`, for the `setTimeout`, `setInterval`, `setImmediate`, `Date`, `scheduler.wait` and `AbortSignal.timeout` APIs. `enable()` takes no options or an object literal whose `apis`, when given, is an array literal. Options and `apis` lists that are not literals, and the `MockTimers` object used as a value, throw `SC2020`. As in Node, the global timer functions, `Date.now()`, `new Date()` and `scheduler.wait` are mocked, and other calls through named and namespace imports of `node:timers` and `node:timers/promises` use real timers. As in Node, once a reset gives `scheduler.wait` back, calling it throws `ERR_INVALID_THIS`. While `scheduler.wait` is mocked, calling it with a `signal` option throws an `SC2020` error. A mock timer's handle converts to a negative number, where Node's mock `Timeout` converts to `NaN`. When two trackers have timers enabled, clearing a timer through the tracker that did not create it leaves the timer queued, where Node removes the entry at the same queue position in the clearing tracker. diff --git a/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx b/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx index afb8ec9cf..54db5e6fc 100644 --- a/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx +++ b/docs/content/limitations/types-and-class-definitions/null-and-optional-values.mdx @@ -1 +1 @@ -- **Null and optional values:** `undefined` and `null` compile as standalone values and union members. Optional record and class fields and optional, default, and rest parameters also compile, including rest parameters typed `unknown[]` or `any[]`, whose functions take any number of arguments. A function value with fixed parameters cannot be stored where such a variadic function type is expected. +- **Null and optional values:** `undefined` and `null` compile as standalone values and union members. Optional record and class fields and optional, default, and rest parameters also compile, including rest parameters typed `unknown[]` or `any[]`, whose functions take any number of arguments. A function value whose fixed parameters are all `unknown` or `any`, or that has none, can be stored where such a variadic function type without fixed parameters is expected: each call passes the arguments in order, `undefined` for the ones the caller left out. Other function values with fixed parameters cannot be stored there. diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 75e789c61..92d145a3b 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -66502,6 +66502,7 @@ "source": "compiler-feature:test.mock.fn", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-fn-default.ts", "tests/harness/node-test.test.ts" ] } diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index c93030602..d953c69e4 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -91073,6 +91073,7 @@ "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/fixtures/node-test/cases/get-test-context-cancelled.ts", "tests/fixtures/node-test/cases/get-test-context.ts", + "tests/fixtures/node-test/cases/mock-fn-default.ts", "tests/fixtures/node-test/cases/mock-fn.ts", "tests/fixtures/node-test/cases/mock-method.ts", "tests/fixtures/node-test/cases/mock-timers-date.ts", @@ -92484,6 +92485,7 @@ "evidence": "compiler-feature:test.mock.fn", "tests": [ "tests/fixtures/node-test/cases/mock-fn.ts", + "tests/fixtures/node-test/cases/mock-fn-default.ts", "tests/harness/node-test.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 71605612e..035d94db5 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -362,7 +362,7 @@ { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.filePath", "passed", "attempt", "tags", "workerId"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/ctx-members.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `SuiteContext`", "symbols": ["context.name", "context.fullName", "context.filePath", "context.diagnostic", "passed", "attempt"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/suite-context.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.diagnostic", "context.name"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] }, - { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.fn"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/harness/node-test.test.ts"] }, + { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.fn"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/fixtures/node-test/cases/mock-fn-default.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.method", "mock.reset", "mock.restoreAll"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-method.ts", "tests/corpus/5810-mock-method-records.ts", "tests/corpus/5811-mock-tracker-restore.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTimers`", "symbols": ["timers.enable", "timers.reset", "timers.tick", "timers.runAll", "timers.setTime"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-timers.ts", "tests/fixtures/node-test/cases/mock-timers-date.ts", "tests/corpus/5813-mock-timers-module.ts", "tests/corpus/5814-mock-timers-date.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTimers`", "signatures": ["`timers[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-timers-date.ts", "tests/harness/node-test.test.ts"] }, diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index b99c24a15..6ae52ee40 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -127,15 +127,71 @@ export function cleanFuncAdaptable( return src.ret.kind === "void" && dst.ret.kind === "dyn"; } +/** Adapt a fixed-parameter function to a checked-dynamic rest signature + * without fixed parameters (`() => {}` into mock.fn()'s `(...args: any[]) + * => undefined`): the adapter receives the hidden argument array and + * passes its element i as parameter i, undefined when the caller passed + * fewer arguments, as JS does. Null unless every parameter is + * checked-dynamic, which holds a missing argument as undefined, and the + * result is discarded or converts. */ +function funcIntoDynRestAdapter( + lowerer: Lowerer, + fromT: IrType & { kind: "func" }, + toT: IrType & { kind: "func" }, + loc: SrcLoc, +): string | null { + if (fromT.rest === true || fromT.params.some((p) => p.kind !== "dyn")) return null; + if (toT.ret.kind !== "void" && !lowerer.coercibleValue(fromT.ret, toT.ret)) return null; + const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`; + const existing = lowerer.coercions.retags.get(key); + if (existing) return existing; + const name = `%fn.adapt.${lowerer.coercions.retags.size}`; + lowerer.coercions.retags.set(key, name); + lowerer.freshClosureAdapters.add(name); + const params: IrParam[] = [{ localId: "rest.0", name: "args", type: DYN }]; + const call: IrExpr = { + kind: "callValue", + callee: { kind: "varRef", localId: "f.0", type: fromT, loc }, + receiver: { kind: "libCall", fn: "dyn.this", args: [], type: DYN, loc }, + args: fromT.params.map((_, i): IrExpr => ({ + kind: "dynKeyGet", + value: { kind: "varRef", localId: "rest.0", type: DYN, loc }, + key: { kind: "strLit", value: String(i), type: STRING, loc }, + type: DYN, + loc, + })), + type: fromT.ret, + loc, + }; + let body: IrStmt[]; + if (toT.ret.kind === "void") { + body = [ + { kind: "exprStmt", expr: call, loc }, + { kind: "return", value: null, loc }, + ]; + } else { + const result = lowerer.coerceToExpected(call, toT.ret); + if (!typeEquals(result.type, toT.ret)) + throw new InternalCompilerError("lowerer bug: probed fn-adapter return stopped coercing"); + body = [{ kind: "return", value: result, loc }]; + } + lowerer.liftedFns.push(...buildFunctionAdapter(name, fromT, toT, params, [], body, loc)); + return name; +} + /** Adapt a function value to a destination signature, forwarding and * converting its arguments and result. Unsupported parameter/result pairs - * produce checked traps when called; rest signatures remain refused. */ + * produce checked traps when called; rest signatures remain refused, + * except a fixed-parameter function into a checked-dynamic rest signature + * without fixed parameters (funcIntoDynRestAdapter). */ export function funcCoerceAdapter( lowerer: Lowerer, fromT: IrType & { kind: "func" }, toT: IrType & { kind: "func" }, loc: SrcLoc, ): string | null { + if (toT.rest === true && toT.restAbi === undefined && toT.params.length === 0) + return funcIntoDynRestAdapter(lowerer, fromT, toT, loc); if (fromT.rest === true || toT.rest === true) return null; if (fromT.params.length > toT.params.length) return null; // Piece dispositions beyond coercibleValue, all CHECKER-APPROVED diff --git a/packages/compiler/src/frontend/lowering/lower-mock.ts b/packages/compiler/src/frontend/lowering/lower-mock.ts index 4f446bb89..be68abaca 100644 --- a/packages/compiler/src/frontend/lowering/lower-mock.ts +++ b/packages/compiler/src/frontend/lowering/lower-mock.ts @@ -154,7 +154,12 @@ function mockWrapperFactory( const { fn, call, calls, once, onceEntry, result, args } = mockContextFields(lowerer, ctxType)!; const impl = `${name}.impl`; const ctx = () => varRef("ctx.0", ctxType, loc); + // A checked-dynamic rest function (mock.fn()'s default type) takes its + // arguments as one trailing dyn array, recorded as the call's arguments + // and spread into the implementation. + const dynRest = fn.rest === true && fn.restAbi === undefined; const params = fn.params.map((type, i) => ({ localId: `p${i}.0`, name: `p${i}`, type })); + if (dynRest) params.push({ localId: "rest.0", name: "args", type: DYN }); const paramRefs = () => params.map((p) => varRef(p.localId, p.type, loc)); const argumentsValue = (): IrExpr => fn.rest @@ -219,13 +224,23 @@ function mockWrapperFactory( loc, }; }; - const invoke: IrExpr = { - kind: "callValue", - callee: varRef("f.0", fn, loc), - args: paramRefs(), - type: fn.ret, - loc, - }; + const invoke: IrExpr = dynRest + ? { + kind: "dynCall", + callee: { kind: "dynFrom", value: varRef("f.0", fn, loc), type: DYN, loc }, + calleeName: "implementation", + args: paramRefs(), + spreads: [{ arg: 0, what: "args" }], + type: DYN, + loc, + } + : { + kind: "callValue", + callee: varRef("f.0", fn, loc), + args: paramRefs(), + type: fn.ret, + loc, + }; const tryBody: IrStmt[] = voidReturn ? [ { kind: "exprStmt", expr: invoke, loc }, @@ -629,7 +644,37 @@ function requireUndefinedResultArm( } } -/** mock.fn(original, implementation?) and t.mock.fn(...). */ +/** `%mock.noop.N`: mock.fn()'s original, Node's `function () {}` — it + * returns undefined. Null when the function type's return type cannot hold + * undefined. */ +function mockNoopOriginal( + lowerer: Lowerer, + ctxType: IrType, + fn: FuncType, + loc: SrcLoc, +): IrExpr | null { + const voidReturn = fn.ret.kind === "void" || fn.ret.kind === "undefinedT"; + const undefinedValue = voidReturn ? null : lowerer.wrappedUndefined(fn.ret, loc); + if (!voidReturn && !undefinedValue) return null; + const name = liftOnce(lowerer, ctxType, "noop", (name) => { + const params = fn.params.map((type, i) => ({ localId: `p${i}.0`, name: `p${i}`, type })); + if (fn.rest === true && fn.restAbi === undefined) + params.push({ localId: "rest.0", name: "args", type: DYN }); + lowerer.liftedFns.push({ + name, + params, + returnType: fn.ret, + locals: params.map((p) => local(p.localId, p.name, p.type)), + body: [{ kind: "return", value: undefinedValue, loc }], + loc, + }); + }); + return { kind: "closure", fnName: name, captures: [], type: fn, loc }; +} + +/** mock.fn(original?, implementation?) and t.mock.fn(...). Without an + * original, the mock's original and implementation are a function that + * returns undefined, as in Node. */ export function lowerMockFn( lowerer: Lowerer, call: ts.CallExpression, @@ -658,11 +703,19 @@ export function lowerMockFn( const { fn } = fields; requireUndefinedResultArm(lowerer, fields, call, what); const args = call.arguments.nodes; - if (args.length === 0 || args.length > 2 || args.some(ts.isSpreadElement)) { + if (args.length > 2 || args.some(ts.isSpreadElement)) { lowerer.noLowering( `${what} with ${args.length} arguments`, call, - "the supported forms are mock.fn(original) and mock.fn(original, implementation); mock.fn() and mock options have no lowering", + "the supported forms are mock.fn(), mock.fn(original) and mock.fn(original, implementation); mock options have no lowering", + ); + } + const noop = args.length === 0 ? mockNoopOriginal(lowerer, ctxType!, fn, loc) : null; + if (args.length === 0 && !noop) { + lowerer.noLowering( + `${what}() of a function returning '${lowerer.fmt(fn.ret)}'`, + call, + "mock.fn() without an original returns undefined: pass an original, or use a function type whose return type includes undefined", ); } const stmts: IrStmt[] = []; @@ -670,7 +723,7 @@ export function lowerMockFn( stmts.push({ kind: "varDecl", localId: originalLocal.id, - init: lowerer.lowerExprExpecting(args[0]!, fn), + init: noop ?? lowerer.lowerExprExpecting(args[0]!, fn), loc, }); const implementation = diff --git a/packages/compiler/src/frontend/mock-shapes.ts b/packages/compiler/src/frontend/mock-shapes.ts index 1baf2cd04..50781c272 100644 --- a/packages/compiler/src/frontend/mock-shapes.ts +++ b/packages/compiler/src/frontend/mock-shapes.ts @@ -12,10 +12,11 @@ * object; empty for mock.fn * * A MockFunctionCall is { arguments, error, result, this }: the - * arguments as a tuple of F's parameters, the result as F's return type or - * undefined (a checked-dynamic value for functions returning void or - * undefined, since a record field cannot have a unit type), and error and - * this as checked-dynamic values. */ + * arguments as a tuple of F's parameters (a checked-dynamic array for + * mock.fn()'s `(...args: any[]) => undefined`), the result as F's return + * type or undefined (a checked-dynamic value for functions returning void + * or undefined, since a record field cannot have a unit type), and error + * and this as checked-dynamic values. */ import { DYN, F64, NULL_T, UNDEFINED_T, VOID, typeKey, type IrType } from "../ir/ir.js"; import type { TypeMapperCtx } from "./type-mapper.js"; @@ -34,11 +35,17 @@ function orUndefined(t: IrType, reg: MockShapeRegistries): IrType { } /** The arguments type of a call record: a tuple of the parameters, or the - * rest array itself for a function whose only parameter is a rest array. - * Null for other rest shapes. A function without parameters records the + * rest array itself for a function whose only parameter is a rest array, + * or a checked-dynamic array for a function returning nothing whose only + * parameter is a checked-dynamic rest (mock.fn()'s default, `(...args: + * any[]) => undefined`). Null for other rest shapes. A function without parameters records the * empty tuple, which maps to an array of the unit-only union, as * type-mapper.ts maps `[]`. */ function mockArgumentsType(fn: IrType & { kind: "func" }, reg: MockShapeRegistries): IrType | null { + if (fn.rest && fn.restAbi === undefined) { + const returnsNothing = fn.ret.kind === "void" || fn.ret.kind === "undefinedT"; + return fn.params.length === 0 && returnsNothing ? DYN : null; + } if (fn.rest) { if (fn.params.length !== 1 || fn.params[0]!.kind !== "array") return null; return fn.params[0]!; diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 123a5d1a0..b4218341b 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4384,10 +4384,13 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null // params). Declaration files keep their existing paths: library // callback slots such as String#replace's `(substring, ...args: any[])` // have intrinsic lowerings that inspect the unmapped type. + // node:test's default mock function type, `(...args: any[]) => + // undefined` (the type parameter default of mock.fn), takes the same + // ABI: mock.fn() without an original records whatever its callers pass. if ( rest && decl !== undefined && - !decl.getSourceFile().isDeclarationFile && + (!decl.getSourceFile().isDeclarationFile || isNodeTestDefaultFunctionParam(decl, ctx)) && (pt?.kind === "dyn" || (!pt && isAnyArrayType(checker.getTypeOfSymbol(p), checker))) ) { dynRest = true; @@ -6364,6 +6367,23 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { } } +/** A parameter of a function type that is the default of a type parameter + * declared in node:test's standard-library declarations (MockTracker.fn's + * `F extends Function = (...args: any[]) => undefined`). */ +function isNodeTestDefaultFunctionParam(decl: ts.Declaration, ctx: TypeMapperCtx): boolean { + const fnType = decl.parent; + return ( + ts.isParameter(decl) && + fnType !== undefined && + ts.isFunctionTypeNode(fnType) && + fnType.parent !== undefined && + ts.isTypeParameterDeclaration(fnType.parent) && + fnType.parent.defaultType === fnType && + ctx.isStdlibFile(decl.getSourceFile()) && + isDeclaredInAmbientModule(decl, "test") + ); +} + /** `any[]` (or `readonly any[]`): an array type whose element is `any`. */ export function isAnyArrayType(t: ts.Type, checker: ts.TypeChecker): boolean { if (!checker.isArrayType(t)) return false; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5816-fixed-into-unknown-rest.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5816-fixed-into-unknown-rest.ts.json new file mode 100644 index 000000000..8354f5c48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5816-fixed-into-unknown-rest.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5816-fixed-into-unknown-rest.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5816-fixed-into-unknown-rest.ts b/tests/corpus/5816-fixed-into-unknown-rest.ts new file mode 100644 index 000000000..f6d287021 --- /dev/null +++ b/tests/corpus/5816-fixed-into-unknown-rest.ts @@ -0,0 +1,39 @@ +// A function with only `unknown` parameters, or none, flows into a +// `(...args: unknown[]) => R` slot: each call passes the callers' +// arguments in order, undefined for a parameter the caller left out, and +// drops the extra ones, as JS calls do. Sync and async functions, stored +// in variables and arrays and passed as arguments. +type Handler = (...args: unknown[]) => void; +type AsyncHandler = (...args: unknown[]) => Promise; + +const none: Handler = () => console.log("none"); +const one: Handler = (a: unknown) => console.log("one", a); +const two: Handler = (a: unknown, b: unknown) => console.log("two", a, b); + +none(); +none(1, 2, 3); +one(); +one("x"); +one("x", "y"); +two(1); +two(1, [2], { three: 3 }); + +const handlers: Handler[] = [none, one, two]; +for (const h of handlers) h("all", true); + +function dispatch(h: Handler, ...args: unknown[]): void { + h(...args); +} +dispatch(two, "spread", null); +dispatch(one); + +const first: AsyncHandler = async (value: unknown) => value; +const pair: AsyncHandler = async (a: unknown, b: unknown) => [a, b]; + +async function main(): Promise { + console.log(await first(10, 20)); + console.log(await first()); + console.log(await pair("p")); + console.log(await pair("p", "q", "r")); +} +main().then(() => console.log("done")); diff --git a/tests/diagnostics/maybe-promise-fences.ts b/tests/diagnostics/maybe-promise-fences.ts index efe541c9e..1dcccaf08 100644 --- a/tests/diagnostics/maybe-promise-fences.ts +++ b/tests/diagnostics/maybe-promise-fences.ts @@ -30,8 +30,3 @@ console.log(typeof widened); // `instanceof Promise` on a value that is never a promise. const record = { v: 1 }; console.log(record instanceof Promise); - -// A variadic `unknown[]` slot takes closures with the same variadic ABI. -type HandlerFn = (...args: unknown[]) => Promise; -const fixed: HandlerFn = async (first: unknown) => first; -fixed(1, 2).then((v) => console.log(v)); diff --git a/tests/diagnostics/mock-fences.ts b/tests/diagnostics/mock-fences.ts index 4425dfa5f..ef92f366f 100644 --- a/tests/diagnostics/mock-fences.ts +++ b/tests/diagnostics/mock-fences.ts @@ -1,8 +1,9 @@ -// node:test mocks lower for mock.fn(original[, implementation]), +// node:test mocks lower for mock.fn([original[, implementation]]), // mock.method(object, methodName[, implementation]) on records, their // t.mock twins, and the trackers' restoreAll and reset. The rest of the // mock surface is refused, each with a named diagnostic: mock.fn() without -// an original; mock.method on a class instance or a builtin object, with +// an original for a function type whose return type does not include +// undefined; mock.method on a class instance or a builtin object, with // an options object, or with an implementation of another type when the // result is used; and functions whose union return type has no undefined // arm (the calls log records undefined for calls that throw). @@ -11,8 +12,11 @@ import { mock, test } from "node:test"; const add = mock.fn((a: number, b: number) => a + b); // lowers add(1, 2); -const empty = mock.fn(); // no original function -empty(); +const empty = mock.fn(); // lowers +empty(1, "two"); + +const counted = mock.fn<() => number>(); // no original, returns number +counted(); const obj = { value: () => 1 }; mock.method(obj, "value"); // lowers diff --git a/tests/fixtures/node-test/cases/mock-fn-default.ts b/tests/fixtures/node-test/cases/mock-fn-default.ts new file mode 100644 index 000000000..4666dfa5e --- /dev/null +++ b/tests/fixtures/node-test/cases/mock-fn-default.ts @@ -0,0 +1,72 @@ +// mock.fn() and t.mock.fn() without an original: the mock returns +// undefined and records the arguments of each call, whatever its callers +// pass. mockImplementation and mockImplementationOnce replace its +// behavior, and restore() brings back the function that returns undefined. +// mock.fn() records F's parameters, for a function type whose return +// type is void or includes undefined. +import assert from "node:assert"; +import { mock, test } from "node:test"; + +test("records calls with any arguments", () => { + const fn = mock.fn(); + fn(1, "two"); + fn(); + fn(true, null, [3]); + assert.strictEqual(fn.mock.callCount(), 3); + const [first, second, third] = fn.mock.calls; + assert.deepStrictEqual(first!.arguments, [1, "two"]); + assert.strictEqual(second!.arguments.length, 0); + assert.deepStrictEqual(third!.arguments, [true, null, [3]]); + assert.ok(first!.result === undefined); + assert.ok(first!.error === undefined); + assert.ok(first!.this === undefined); +}); + +test("changes implementations", () => { + const seen: string[] = []; + const fn = mock.fn(); + fn.mock.mockImplementation((...args: unknown[]) => { + seen.push(`impl ${args.length}`); + return undefined; + }); + fn.mock.mockImplementationOnce(() => { + seen.push("once"); + return undefined; + }); + fn("a"); + fn("a", "b"); + fn.mock.restore(); + fn("c"); + assert.deepStrictEqual(seen, ["once", "impl 2"]); + assert.strictEqual(fn.mock.callCount(), 3); + fn.mock.resetCalls(); + assert.strictEqual(fn.mock.callCount(), 0); +}); + +test("records a throwing implementation", () => { + const fn = mock.fn(); + fn.mock.mockImplementation(() => { + throw new Error("boom"); + }); + assert.throws(() => fn(7), { message: "boom" }); + const call = fn.mock.calls[0]!; + assert.deepStrictEqual(call.arguments, [7]); + assert.ok(call.error instanceof Error); +}); + +test("t.mock.fn() and typed mock.fn()", (t) => { + const fn = t.mock.fn(); + fn("x"); + assert.strictEqual(fn.mock.callCount(), 1); + const typed = t.mock.fn<(a: number, b: string) => void>(); + typed(1, "x"); + typed(2, "y"); + assert.strictEqual(typed.mock.callCount(), 2); + assert.strictEqual(typed.mock.calls[1]!.arguments[0], 2); + assert.strictEqual(typed.mock.calls[1]!.arguments[1], "y"); + const maybe = t.mock.fn<() => number | undefined>(); + assert.ok(maybe() === undefined); + maybe.mock.mockImplementation(() => 4); + assert.ok(maybe() === 4); + assert.ok(maybe.mock.calls[1]!.result === 4); +}); diff --git a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt index 58fb23517..159e88f76 100644 --- a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt +++ b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt @@ -55,11 +55,4 @@ maybe-promise-fences.ts:32:13 - error SC1090: 'instanceof Promise' on '{ v: numb 31 | const record = { v: 1 }; 32 | console.log(record instanceof Promise); | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 33 | - -maybe-promise-fences.ts:36:26 - error SC1090: '(unknown) => Promise' values where '() => Promise' is expected is not supported yet - - 35 | type HandlerFn = (...args: unknown[]) => Promise; - 36 | const fixed: HandlerFn = async (first: unknown) => first; - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 37 | fixed(1, 2).then((v) => console.log(v)); \ No newline at end of file + 33 | \ No newline at end of file diff --git a/tests/harness/__snapshots__/mock-fences.ts.txt b/tests/harness/__snapshots__/mock-fences.ts.txt index 6b4defcbb..d4b8115e8 100644 --- a/tests/harness/__snapshots__/mock-fences.ts.txt +++ b/tests/harness/__snapshots__/mock-fences.ts.txt @@ -1,62 +1,62 @@ -mock-fences.ts:14:15 - error SC2020: 'mock.fn of this function type' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:18:17 - error SC2020: 'mock.fn() of a function returning 'number'' is part of the standard library types but has no scriptc lowering yet - 13 | - 14 | const empty = mock.fn(); // no original function - | ^~~~~~~~~ - 15 | empty(); + 17 | + 18 | const counted = mock.fn<() => number>(); // no original, returns number + | ^~~~~~~~~~~~~~~~~~~~~~~ + 19 | counted(); - hint: mock functions lower for functions with fixed parameters, or a single rest array parameter, and an implementation of the same type + hint: mock.fn() without an original returns undefined: pass an original, or use a function type whose return type includes undefined -mock-fences.ts:27:13 - error SC2020: 'mock.method on 'Counter'' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:31:13 - error SC2020: 'mock.method on 'Counter'' is part of the standard library types but has no scriptc lowering yet - 26 | } - 27 | mock.method(new Counter(), "count"); // class instance + 30 | } + 31 | mock.method(new Counter(), "count"); // class instance | ^~~~~~~~~~~~~ - 28 | mock.method(console, "log"); // builtin object + 32 | mock.method(console, "log"); // builtin object hint: mock.method lowers for objects whose type maps to a record (object literals and interface-typed objects) and a function-valued field named by a string literal -mock-fences.ts:28:13 - error SC2020: 'mock.method on this object' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:32:13 - error SC2020: 'mock.method on this object' is part of the standard library types but has no scriptc lowering yet - 27 | mock.method(new Counter(), "count"); // class instance - 28 | mock.method(console, "log"); // builtin object + 31 | mock.method(new Counter(), "count"); // class instance + 32 | mock.method(console, "log"); // builtin object | ^~~~~~~ - 29 | mock.method(obj, "value", { times: 1 }); // options + 33 | mock.method(obj, "value", { times: 1 }); // options hint: mock.method lowers for objects whose type maps to a record (object literals and interface-typed objects) and a function-valued field named by a string literal -mock-fences.ts:29:27 - error SC2020: 'mock.method with an options object' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:33:27 - error SC2020: 'mock.method with an options object' is part of the standard library types but has no scriptc lowering yet - 28 | mock.method(console, "log"); // builtin object - 29 | mock.method(obj, "value", { times: 1 }); // options + 32 | mock.method(console, "log"); // builtin object + 33 | mock.method(obj, "value", { times: 1 }); // options | ^~~~~~~~~~~~ - 30 | const other = mock.method(obj, "value", () => "one"); // implementation of another type + 34 | const other = mock.method(obj, "value", () => "one"); // implementation of another type hint: mock options (times, getter and setter) have no lowering -mock-fences.ts:30:41 - error SC2020: 'mock.method with an implementation of another type' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:34:41 - error SC2020: 'mock.method with an implementation of another type' is part of the standard library types but has no scriptc lowering yet - 29 | mock.method(obj, "value", { times: 1 }); // options - 30 | const other = mock.method(obj, "value", () => "one"); // implementation of another type + 33 | mock.method(obj, "value", { times: 1 }); // options + 34 | const other = mock.method(obj, "value", () => "one"); // implementation of another type | ^~~~~~~~~~~ - 31 | other(); + 35 | other(); hint: give the implementation the method's parameter and return types, or discard the result -mock-fences.ts:35:31 - error SC2020: 't.mock.method with an options object' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:39:31 - error SC2020: 't.mock.method with an options object' is part of the standard library types but has no scriptc lowering yet - 34 | t.mock.method(obj, "value"); // lowers - 35 | t.mock.method(obj, "value", { times: 2 }); // options + 38 | t.mock.method(obj, "value"); // lowers + 39 | t.mock.method(obj, "value", { times: 2 }); // options | ^~~~~~~~~~~~ - 36 | }); + 40 | }); hint: mock options (times, getter and setter) have no lowering -mock-fences.ts:38:14 - error SC2020: 'mock.fn of a function returning 'number | string'' is part of the standard library types but has no scriptc lowering yet +mock-fences.ts:42:14 - error SC2020: 'mock.fn of a function returning 'number | string'' is part of the standard library types but has no scriptc lowering yet - 37 | - 38 | const pick = mock.fn((flag: boolean): number | string => (flag ? 1 : "one")); // union without undefined + 41 | + 42 | const pick = mock.fn((flag: boolean): number | string => (flag ? 1 : "one")); // union without undefined | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 39 | pick(true); + 43 | pick(true); hint: a union return type needs an undefined arm here (the calls log records undefined for calls that throw) \ No newline at end of file diff --git a/tests/harness/corpus-costs/5816-fixed-into-unknown-rest.ts.json b/tests/harness/corpus-costs/5816-fixed-into-unknown-rest.ts.json new file mode 100644 index 000000000..2b149eb6a --- /dev/null +++ b/tests/harness/corpus-costs/5816-fixed-into-unknown-rest.ts.json @@ -0,0 +1 @@ +{ "plain": 3028, "san": 5470 } From 503154e3874f3c82f1954377f9e5b6adc0744c3a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 15:21:03 -0700 Subject: [PATCH 34/85] Compile the boot-path Node surface: process.send presence, process.exitCode reads, wider stream hooks, void race entries, RequestOptions and promisify of callbacks - `if (process.send)`, `Boolean(process.send)`, `!process.send` and `typeof process.send` answer whether the process has an IPC channel (a fork channel this binary opened, or NODE_CHANNEL_FD from a Node.js parent). The same holds for process.disconnect. - In a program without fork targets, `process.send?.(...)` answers undefined, and a direct call throws Node's TypeError when there is no channel. A Node.js parent's channel has no lowering, so a call fences when it runs. `process.send!(...)` lowers like the direct call. - process.exitCode reads answer the assigned code, or undefined. Only programs that read it record assignments, including process.exit(code) before the 'exit' listeners run. Other programs keep the untracked write. - A stream subclass hook may declare a supertype of Node's argument (`_write(chunk: Buffer | string, ...)`): the wrapper takes Node's Buffer and widens it. - Promise.race accepts a Promise entry when the result holds undefined. - http.RequestOptions and https.RequestOptions as declared types ride checked-dynamic storage, like SpawnOptions. - util.promisify over a typed callback-last function value (a bound method, a declaration, an arrow) builds a native promisified function. A truthy error rejects, the value fulfills, and a synchronous throw rejects. - The bundled Node declarations gain the callback-only generic promisify overloads. Before this, `promisify(obj.close.bind(obj))` matched crypto.randomFill's overload and reported "Expected 1-3 arguments". Co-Authored-By: Claude Opus 5.5 --- .../generated/node-v24-backlog.json | 27 +- .../generated/node-v24-internal.json | 34 +- internal/compatibility/static-support.json | 6 +- .../ambient/scriptc-node-fallback.d.ts | 7 + .../src/backend/llvm/expr-callbacks.ts | 17 +- .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../src/frontend/lowering/builtin-calls.ts | 5 + .../lowering/builtins/callable-aliases.ts | 8 +- .../src/frontend/lowering/builtins/ipc.ts | 58 ++- .../src/frontend/lowering/builtins/process.ts | 213 ++++++++++- .../frontend/lowering/builtins/promises.ts | 3 + .../lowering/builtins/promisify-callback.ts | 357 ++++++++++++++++++ .../src/frontend/lowering/lower-exprs.ts | 20 +- .../src/frontend/lowering/lower-stmts.ts | 7 +- .../src/frontend/lowering/lower-stream.ts | 24 +- .../compiler/src/frontend/lowering/lowerer.ts | 2 + .../src/frontend/lowering/surfaces.ts | 6 +- packages/compiler/src/frontend/type-mapper.ts | 23 ++ packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 8 + packages/compiler/src/ir/runtime-features.ts | 1 + packages/compiler/surface-manifest.json | 4 +- .../corpus/6262-process-send-presence.ts.json | 6 + .../main.ts.json | 6 + .../corpus/6264-process-exitcode-read.ts.json | 6 + .../6265-process-exitcode-default.ts.json | 6 + .../6266-process-exit-keeps-exitcode.ts.json | 6 + .../6267-stream-hook-wider-chunk.ts.json | 6 + .../6268-promise-race-void-entry.ts.json | 6 + .../6269-http-request-options-type.ts.json | 6 + .../6270-promisify-callback-functions.ts.json | 6 + .../boot-path-node-surface-fences.ts.json | 6 + .../promisify-callback-functions.ts.json | 6 + packages/runtime/src/scr_child.c | 1 + packages/runtime/src/scr_child.h | 3 + packages/runtime/src/scr_lib.c | 13 + packages/runtime/src/scr_lib.h | 5 + tests/corpus/6262-process-send-presence.ts | 24 ++ .../6263-process-send-presence-fork/main.ts | 13 + .../6263-process-send-presence-fork/worker.ts | 6 + tests/corpus/6264-process-exitcode-read.ts | 22 ++ tests/corpus/6265-process-exitcode-default.ts | 10 + .../6266-process-exit-keeps-exitcode.ts | 5 + tests/corpus/6267-stream-hook-wider-chunk.ts | 46 +++ tests/corpus/6268-promise-race-void-entry.ts | 31 ++ .../corpus/6269-http-request-options-type.ts | 10 + .../6270-promisify-callback-functions.ts | 65 ++++ .../boot-path-node-surface-fences.ts | 23 ++ .../promisify-callback-functions.ts | 67 ++++ .../boot-path-node-surface-fences.ts.txt | 35 ++ tests/harness/project-config.test.ts | 18 + 51 files changed, 1267 insertions(+), 30 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-process-send-presence.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-process-send-presence-fork/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6264-process-exitcode-read.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6265-process-exitcode-default.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6266-process-exit-keeps-exitcode.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6267-stream-hook-wider-chunk.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6268-promise-race-void-entry.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6269-http-request-options-type.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6270-promisify-callback-functions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/boot-path-node-surface-fences.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/promisify-callback-functions.ts.json create mode 100644 tests/corpus/6262-process-send-presence.ts create mode 100644 tests/corpus/6263-process-send-presence-fork/main.ts create mode 100644 tests/corpus/6263-process-send-presence-fork/worker.ts create mode 100644 tests/corpus/6264-process-exitcode-read.ts create mode 100644 tests/corpus/6265-process-exitcode-default.ts create mode 100644 tests/corpus/6266-process-exit-keeps-exitcode.ts create mode 100644 tests/corpus/6267-stream-hook-wider-chunk.ts create mode 100644 tests/corpus/6268-promise-race-void-entry.ts create mode 100644 tests/corpus/6269-http-request-options-type.ts create mode 100644 tests/corpus/6270-promisify-callback-functions.ts create mode 100644 tests/diagnostics/boot-path-node-surface-fences.ts create mode 100644 tests/fixtures/node-types/promisify-callback-functions.ts create mode 100644 tests/harness/__snapshots__/boot-path-node-surface-fences.ts.txt diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 75e789c61..6b570257e 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -55949,7 +55949,9 @@ "confidence": "high", "source": "compiler-feature:process.'disconnect'", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] } } @@ -55991,7 +55993,9 @@ "confidence": "high", "source": "compiler-feature:process.'message'", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] } } @@ -56374,7 +56378,9 @@ "confidence": "high", "source": "compiler-feature:process.process.disconnect", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] } } @@ -57022,7 +57028,9 @@ "confidence": "high", "source": "compiler-feature:process.process.send", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] } } @@ -57476,7 +57484,9 @@ "confidence": "high", "source": "compiler-feature:process.connected", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] } } @@ -57591,7 +57601,10 @@ "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", - "tests/corpus/2944-process-exit-code-wrap.ts" + "tests/corpus/2944-process-exit-code-wrap.ts", + "tests/corpus/6264-process-exitcode-read.ts", + "tests/corpus/6265-process-exitcode-default.ts", + "tests/corpus/6266-process-exit-keeps-exitcode.ts" ] } } @@ -76174,6 +76187,8 @@ "tests/fixtures/node-types/promisify-crypto.ts", "tests/corpus/5632-promisify-dns.ts", "tests/fixtures/node-types/promisify-dns.ts", + "tests/corpus/6270-promisify-callback-functions.ts", + "tests/fixtures/node-types/promisify-callback-functions.ts", "tests/harness/project-config.test.ts" ] } diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index c93030602..d95dba97a 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79378,6 +79378,11 @@ "tests/corpus/5210-process-memory-usage.ts", "tests/corpus/5211-process-identity.ts", "tests/corpus/5809-process-exit-and-rejections.cjs", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts", + "tests/corpus/6264-process-exitcode-read.ts", + "tests/corpus/6265-process-exitcode-default.ts", + "tests/corpus/6266-process-exit-keeps-exitcode.ts", "tests/corpus/990-process-basics.ts", "tests/corpus/node24-env-files-errors.ts", "tests/corpus/node24-env-files.ts", @@ -79455,7 +79460,9 @@ "status": "partial", "evidence": "compiler-feature:process.'disconnect'", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] }, "anchorSource": "exact" @@ -79494,7 +79501,9 @@ "status": "partial", "evidence": "compiler-feature:process.'message'", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] }, "anchorSource": "exact" @@ -79907,7 +79916,9 @@ "status": "partial", "evidence": "compiler-feature:process.process.disconnect", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] }, "anchorSource": "exact" @@ -80552,7 +80563,9 @@ "status": "partial", "evidence": "compiler-feature:process.process.send", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] }, "anchorSource": "exact" @@ -80971,7 +80984,9 @@ "status": "partial", "evidence": "compiler-feature:process.connected", "tests": [ - "tests/corpus/2946-child-fork-ipc/main.ts" + "tests/corpus/2946-child-fork-ipc/main.ts", + "tests/corpus/6262-process-send-presence.ts", + "tests/corpus/6263-process-send-presence-fork/main.ts" ] }, "anchorSource": "chapter" @@ -81080,7 +81095,10 @@ "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", - "tests/corpus/2944-process-exit-code-wrap.ts" + "tests/corpus/2944-process-exit-code-wrap.ts", + "tests/corpus/6264-process-exitcode-read.ts", + "tests/corpus/6265-process-exitcode-default.ts", + "tests/corpus/6266-process-exit-keeps-exitcode.ts" ] }, "anchorSource": "chapter" @@ -101165,6 +101183,7 @@ "tests/corpus/5832-util-types-brands-dynamic.js", "tests/corpus/5835-util-format-integer-specifiers.ts", "tests/corpus/5884-inspect-layout-options.ts", + "tests/corpus/6270-promisify-callback-functions.ts", "tests/corpus/node24-deep-equality-properties.js", "tests/corpus/node24-deep-equality.cjs", "tests/corpus/node24-deep-equality.ts", @@ -101191,6 +101210,7 @@ "tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/deep-equality.ts", "tests/fixtures/node-types/env-files.ts", + "tests/fixtures/node-types/promisify-callback-functions.ts", "tests/fixtures/node-types/promisify-crypto.ts", "tests/fixtures/node-types/promisify-dns.ts", "tests/fixtures/node-types/promisify-fs.ts", @@ -102849,6 +102869,8 @@ "tests/fixtures/node-types/promisify-crypto.ts", "tests/corpus/5632-promisify-dns.ts", "tests/fixtures/node-types/promisify-dns.ts", + "tests/corpus/6270-promisify-callback-functions.ts", + "tests/fixtures/node-types/promisify-callback-functions.ts", "tests/harness/project-config.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 71605612e..106d38d00 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -148,7 +148,7 @@ "node-builtin.process.versions": ["tests/corpus/process-versions-storage.ts"], "node-builtin.process.rows": ["tests/corpus/2992-stream-rows.ts", "tests/harness/terminal-geometry.test.ts"], "node-builtin.process.columns": ["tests/corpus/1448-stream-columns.ts", "tests/harness/terminal-geometry.test.ts"], - "node-builtin.process.exitCode": ["tests/corpus/5809-process-exit-and-rejections.cjs", "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", "tests/corpus/2944-process-exit-code-wrap.ts"], + "node-builtin.process.exitCode": ["tests/corpus/5809-process-exit-and-rejections.cjs", "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", "tests/corpus/2944-process-exit-code-wrap.ts", "tests/corpus/6264-process-exitcode-read.ts", "tests/corpus/6265-process-exitcode-default.ts", "tests/corpus/6266-process-exit-keeps-exitcode.ts"], "node-builtin.punycode": ["tests/corpus/6122-punycode-conversions.ts", "tests/corpus/6124-punycode-require-warnings.cjs", "tests/corpus/6126-punycode-dependency/main.cjs"], "node-builtin.querystring": ["tests/corpus/957-builtins-namespace.ts", "tests/corpus/2917-builtin-callable-widths.ts"], "node-builtin.readline": ["tests/corpus/1475-readline-closed-stdin.ts"], @@ -333,7 +333,7 @@ { "chapter": "fs", "signatures": ["`fsPromises.lstat(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2941-fsp-lstat-identity.ts"] }, { "chapter": "fs", "signatures": ["`fs.globSync(pattern[, options])`", "`fs.glob(pattern[, options], callback)`", "`fsPromises.glob(pattern[, options])`"], "status": "partial", "evidence": ["tests/corpus/5448-fs-glob-patterns.ts", "tests/corpus/5449-fs-glob-options.ts", "tests/corpus/5450-fs-glob-validation.js", "tests/corpus/5451-fs-promises-glob.ts", "tests/corpus/5452-fs-glob-callback.ts", "tests/fixtures/node-types/fs-glob.ts"] }, { "chapter": "fs", "signatures": ["`fs.cpSync(src, dest[, options])`", "`fs.cp(src, dest[, options], callback)`", "`fsPromises.cp(src, dest[, options])`"], "status": "partial", "evidence": ["tests/corpus/5567-fs-cpsync.ts", "tests/corpus/5568-fs-cp-errors.js", "tests/corpus/5569-fs-cp-symlinks.ts", "tests/corpus/5570-fs-cp-async.ts"] }, - { "chapter": "process", "signatures": ["`process.send(message[, sendHandle[, options]][, callback])`", "`process.disconnect()`", "`connected` Type: {boolean}", "Event: `'message'`", "Event: `'disconnect'`"], "status": "partial", "evidence": ["tests/corpus/2946-child-fork-ipc/main.ts"] }, + { "chapter": "process", "signatures": ["`process.send(message[, sendHandle[, options]][, callback])`", "`process.disconnect()`", "`connected` Type: {boolean}", "Event: `'message'`", "Event: `'disconnect'`"], "status": "partial", "evidence": ["tests/corpus/2946-child-fork-ipc/main.ts", "tests/corpus/6262-process-send-presence.ts", "tests/corpus/6263-process-send-presence-fork/main.ts"] }, { "chapter": "fs", "symbols": ["fs.Stats", "stats.isBlockDevice", "stats.isCharacterDevice", "stats.isDirectory", "stats.isFIFO", "stats.isFile", "stats.isSocket", "stats.isSymbolicLink"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, { "chapter": "fs", "parent": "Class: `fs.Stats`", "signatures": ["`mode` Type: {number|bigint}", "`nlink` Type: {number|bigint}", "`uid` Type: {number|bigint}", "`gid` Type: {number|bigint}", "`rdev` Type: {number|bigint}", "`size` Type: {number|bigint}", "`blksize` Type: {number|bigint}", "`blocks` Type: {number|bigint}", "`atimeMs` Type: {number|bigint}", "`mtimeMs` Type: {number|bigint}", "`birthtimeMs` Type: {number|bigint}", "`atimeNs` Type: {bigint}", "`mtimeNs` Type: {bigint}", "`ctimeNs` Type: {bigint}", "`birthtimeNs` Type: {bigint}", "`atime` Type: {Date}", "`mtime` Type: {Date}", "`ctime` Type: {Date}", "`birthtime` Type: {Date}"], "status": "partial", "evidence": ["tests/corpus/5541-fs-stats-fields.cjs"] }, { "chapter": "fs", "signatures": ["`dev` Type: {number|bigint}", "`ino` Type: {number|bigint}", "`ctimeMs` Type: {number|bigint}"], "status": "partial", "evidence": ["tests/corpus/2941-fsp-lstat-identity.ts"] }, @@ -370,7 +370,7 @@ { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.before", "context.after", "context.beforeEach", "context.afterEach", "context.plan", "context.fullName"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["getTestContext"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/get-test-context.ts", "tests/fixtures/node-test/cases/get-test-context-cancelled.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["describe.skip", "describe.todo", "describe.only", "suite.skip", "suite.todo", "suite.only", "it.skip", "it.todo", "it.only"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts"] }, - { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/corpus/5630-promisify-zlib.ts", "tests/fixtures/node-types/promisify-zlib.ts", "tests/corpus/5631-promisify-crypto.ts", "tests/fixtures/node-types/promisify-crypto.ts", "tests/corpus/5632-promisify-dns.ts", "tests/fixtures/node-types/promisify-dns.ts", "tests/harness/project-config.test.ts"] }, + { "chapter": "util", "signatures": ["`util.promisify(original)`"], "status": "partial", "evidence": ["tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/5625-promisify-exec.ts", "tests/corpus/5626-promisify-forms.ts", "tests/fixtures/node-types/promisify-fs.ts", "tests/corpus/5628-promisify-timers.ts", "tests/fixtures/node-types/promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/corpus/5630-promisify-zlib.ts", "tests/fixtures/node-types/promisify-zlib.ts", "tests/corpus/5631-promisify-crypto.ts", "tests/fixtures/node-types/promisify-crypto.ts", "tests/corpus/5632-promisify-dns.ts", "tests/fixtures/node-types/promisify-dns.ts", "tests/corpus/6270-promisify-callback-functions.ts", "tests/fixtures/node-types/promisify-callback-functions.ts", "tests/harness/project-config.test.ts"] }, { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/corpus/2707-type-import-link-static-default-reexport/main.ts", "tests/harness/errors.test.ts"] }, { "chapter": "zlib", "signatures": ["Class: `zlib.Deflate`", "Class: `zlib.Inflate`", "Class: `zlib.Gzip`", "Class: `zlib.Gunzip`", "Class: `zlib.DeflateRaw`", "Class: `zlib.InflateRaw`", "Class: `zlib.Unzip`", "Class: `zlib.ZlibBase`", "`zlib.close([callback])`", "`zlib.flush([kind, ]callback)`", "`zlib.params(level, strategy, callback)`", "`zlib.reset()`", "`bytesWritten` Type: {number}", "Class: `Options`"], "status": "partial", "evidence": ["tests/corpus/5370-zlib-stream-roundtrip.ts", "tests/corpus/5371-zlib-stream-chunking.cjs", "tests/corpus/5372-zlib-stream-errors.cjs", "tests/corpus/5373-zlib-stream-options.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts", "tests/corpus/5375-zlib-stream-promises.ts", "tests/corpus/5377-zlib-stream-backpressure.cjs"] }, { "chapter": "zlib", "signatures": ["`zlib.constants`"], "status": "partial", "evidence": ["tests/corpus/5376-zlib-constants.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts"] } diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 3b522dd3e..3aca532ce 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3267,6 +3267,13 @@ declare module "util" { info: import("node:crypto").BinaryLike, keylen: number, ) => Promise; + /* A callback-only function (`obj.close.bind(obj)`): before the targets + * whose leading parameters would otherwise capture it. Builtins that take + * arguments before their callback never match these. */ + export function promisify( + fn: (callback: (err: any, result: TResult) => void) => void, + ): () => Promise; + export function promisify(fn: (callback: (err?: any) => void) => void): () => Promise; export function promisify( fn: typeof import("node:crypto").randomFill, ): ( diff --git a/packages/compiler/src/backend/llvm/expr-callbacks.ts b/packages/compiler/src/backend/llvm/expr-callbacks.ts index d71df333a..db1dca1af 100644 --- a/packages/compiler/src/backend/llvm/expr-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-callbacks.ts @@ -1,6 +1,13 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { type IrType, isRefCounted, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; +import { + type IrType, + UNDEFINED_T, + isRefCounted, + isUnitType, + typeEquals, + typeKey, +} from "../../ir/ir.js"; import { mangleGenResThunk, mangleRecordNew, mangleRecordStruct } from "../mangle.js"; import { FN_ATTRS, llFieldType, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -68,6 +75,14 @@ export function raceAdapterFor(host: LlvmEmitterContext, from: IrType, to: IrTyp `define internal void @${sym}(ptr %dst, ptr %src) ${FN_ATTRS} { ; race ${key}`, `entry:`, ]; + if (from.kind === "void") { + // A Promise entry fulfills with undefined: the result's + // undefined arm, whatever the entry's payload slot holds. + const tag = tagOf(UNDEFINED_T); + d.push(` ${fulfill(host.unitInstanceRef(to.unionId, tag))}`, ` ret void`, `}`, ``); + host.resolveThunkDefs.push(...d); + return sym; + } if (from.kind !== "union") { // One arm wrap, straight off the payload accessors. const tag = tagOf(from); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 73b6d72e7..4c7eea4af 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -294,9 +294,12 @@ export const LIB_FN_SYMS: Record = { "process.rusage": "scr_process_rusage", "process.activeResources": "scr_active_resources", "process.exiting": "scr_process_exiting", + "process.ipcOpen": "scr_process_ipc_open", "process.exit": "scr_process_exit", "process.setExitCode": "scr_process_set_exit_code", "process.currentExitCode": "scr_process_exit_code_or_zero", + "process.setExitCodeTracked": "scr_process_set_exit_code_tracked", + "process.hasExitCode": "scr_process_has_exit_code", "process.envSet": "scr_env_set", "process.envUnset": "scr_env_unset", "process.envPairs": "scr_env_pairs", diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index 7e1852600..6be798de8 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -12,6 +12,7 @@ import { lowerEventsEmitterHelper, lowerEventsOnce } from "./lower-event-emitter import type { Lowerer } from "./lowerer.js"; import { lowerTimersMemberCall } from "./lower-timers.js"; import { lowerStreamModuleCall } from "./lower-stream.js"; +import { lowerCallbackPromisify } from "./builtins/promisify-callback.js"; import { lowerNodeModuleCall } from "./builtins/module-resolution.js"; import { builtinModuleFnOf, builtinFenceHintOf } from "./surfaces.js"; @@ -87,6 +88,10 @@ export function lowerBuiltinCall( if (testServed) return testServed; const utilServed = lowerer.lowerUtilModuleCall(expr, bi, loc); if (utilServed) return utilServed; + if (bi.module === "util" && bi.member === "promisify") { + const promisified = lowerCallbackPromisify(lowerer, expr, loc); + if (promisified) return promisified; + } const streamServed = lowerStreamModuleCall(lowerer, expr, bi, loc); if (streamServed) return streamServed; const fsTs = lowerer.lowerFsToUnixTimestampCall(expr, bi, loc); diff --git a/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts b/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts index 1669978af..afb221d04 100644 --- a/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts +++ b/packages/compiler/src/frontend/lowering/builtins/callable-aliases.ts @@ -1,11 +1,12 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; -import { isJsSourceFile } from "../../program.js"; +import { isJsSourceFile, locOf } from "../../program.js"; import { builtinModuleFnOf } from "../surfaces.js"; import { registerHttpClientFnBinding } from "../lower-server.js"; import { isPromisifiedZlibMember } from "./compression.js"; import { isPromisifiedCryptoMember } from "./crypto.js"; import { isPromisifiedDnsMember } from "../lower-dgram.js"; +import { callbackPromisifyShape } from "./promisify-callback.js"; /** Compile-time util.promisify projections. The immutable result binding * owns no runtime slot: calls rewrite to the target's promise lowering, @@ -164,11 +165,14 @@ export function registerPromisifiedBuiltinDecl( : target?.module === "dns" && isPromisifiedDnsMember(target.member) ? { kind: "promisified-dns" as const, member: target.member } : null; + // A typed callback-last function value: the call lowers to a native + // promisified function, and the binding holds it like any closure. + if (!projection && callbackPromisifyShape(lowerer, e, locOf(e)) !== null) return false; if (!projection) { lowerer.noLowering( "util.promisify of this target", argNode ?? e, - "child_process.exec, child_process.execFile, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions (randomBytes, pbkdf2, scrypt, hkdf, randomFill, randomInt, generateKey), dns.lookup, dns.lookupService and the fs callback functions with an fs/promises counterpart are the supported targets", + "child_process.exec, child_process.execFile, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions (randomBytes, pbkdf2, scrypt, hkdf, randomFill, randomInt, generateKey), dns.lookup, dns.lookupService, the fs callback functions with an fs/promises counterpart, and typed callback-last functions whose callback takes (error?: Error | null, value?) are the supported targets", ); } const symbol = lowerer.checker.getSymbolAtLocation(nameNode); diff --git a/packages/compiler/src/frontend/lowering/builtins/ipc.ts b/packages/compiler/src/frontend/lowering/builtins/ipc.ts index cf9d96117..90448854d 100644 --- a/packages/compiler/src/frontend/lowering/builtins/ipc.ts +++ b/packages/compiler/src/frontend/lowering/builtins/ipc.ts @@ -1,7 +1,17 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; -import { BOOL, type IrExpr, type IrType, STRING, type SrcLoc } from "../../../ir/ir.js"; +import { + BOOL, + type IrExpr, + type IrStmt, + type IrType, + STRING, + type SrcLoc, + UNDEFINED_T, +} from "../../../ir/ir.js"; +import { nodeThrowExpr } from "../../../ir/build.js"; +import { processHasIpc } from "./process.js"; /** Method calls on ChildProcess receivers: `child.on("exit"|"error", cb)` * registers a listener with the event loop's child registry. The event @@ -86,8 +96,54 @@ export function ipcSendCallback(lowerer: Lowerer, node: ts.Expression): IrExpr { return cb; } +/** process.send in a program without fork targets. This binary never opens + * a channel then, so only a Node.js parent's NODE_CHANNEL_FD can define + * process.send, and that channel has no lowering: a call fences when it runs. + * Without any channel the calls behave as Node's do on an undefined member: + * `process.send?.(...)` skips its arguments and answers undefined, and + * `process.send(...)` evaluates them and throws a TypeError. The message is + * never sent, so its type is not checked. */ +function lowerChannelLessSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr { + const loc = locOf(call); + const fence = (type: IrType): IrExpr => + nodeThrowExpr( + 0, + "SC2020", + "process.send over an IPC channel opened by a Node.js parent (NODE_CHANNEL_FD) is not supported yet", + type, + loc, + ); + const hasIpc = processHasIpc(lowerer, loc); + if (call.questionDotToken) { + const type = lowerer.mapTypeOf(lowerer.typeOf(call)) ?? BOOL; + const absent: IrExpr = + type.kind === "bool" + ? { kind: "boolLit", value: false, type: BOOL, loc } + : lowerer.coerceToExpected( + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type, + ); + return { kind: "ternary", cond: hasIpc, then: fence(type), else_: absent, type, loc }; + } + const stmts: IrStmt[] = call.arguments.nodes.map((arg) => ({ + kind: "exprStmt", + expr: lowerer.lowerExpr(ts.isSpreadElement(arg) ? arg.expression : arg), + loc: locOf(arg), + })); + const result: IrExpr = { + kind: "ternary", + cond: hasIpc, + then: fence(BOOL), + else_: nodeThrowExpr(1, "", "process.send is not a function", BOOL, loc), + type: BOOL, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc }; +} + export function lowerProcessIpcSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr { const loc = locOf(call); + if (lowerer.forkTargets.length === 0) return lowerChannelLessSend(lowerer, call); if ( call.arguments.nodes.length < 1 || call.arguments.nodes.length > 2 || diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index e4e5004c1..4e6898de3 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -17,6 +17,7 @@ import { type IrExpr, type IrLibFn, type IrStmt, + type IrType, STRING, type SrcLoc, UNDEFINED_T, @@ -29,6 +30,144 @@ import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js"; +/** Whether the process has an IPC channel, Node's test for defining + * process.send and process.disconnect: a channel this fork child opened + * (programs with fork targets only), or a non-empty NODE_CHANNEL_FD from a + * Node.js parent, the variable Node itself tests at startup. Built from the + * existing environment read, so the presence test links no runtime code. */ +export function processHasIpc(lowerer: Lowerer, loc: SrcLoc): IrExpr { + const envT = lowerer.envValueType(); + const unionId = (envT as IrType & { kind: "union" }).unionId; + const stringTag = lowerer.armTag(unionId, STRING); + const value = lowerer.declareHiddenLocal("%channelFd", envT); + const fromNode: IrExpr = { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: value.id, + init: { + kind: "libCall", + fn: "process.envGet", + args: [strLit("NODE_CHANNEL_FD", loc)], + type: envT, + loc, + }, + loc, + }, + ], + result: { + kind: "logical", + op: "&&", + left: { + kind: "unionIsTag", + unionId, + tag: stringTag, + negated: false, + value: varRef(value.id, envT, loc), + type: BOOL, + loc, + }, + right: { + kind: "toBool", + operand: { + kind: "unionNarrow", + unionId, + tag: stringTag, + value: varRef(value.id, envT, loc), + type: STRING, + loc, + }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + if (lowerer.forkTargets.length === 0) return fromNode; + return { + kind: "logical", + op: "||", + left: { kind: "libCall", fn: "process.ipcOpen", args: [], type: BOOL, loc }, + right: fromNode, + type: BOOL, + loc, + }; +} + +/** True when the value of `node` is consumed only by ToBoolean: a + * condition, the operand of `!`, `Boolean(node)`, or an operand of `&&` / + * `||` whose own value is consumed that way (or discarded, for the left + * operand). */ +export function isTruthinessOnlyRead(lowerer: Lowerer, node: ts.Expression): boolean { + let child: ts.Node = node; + let parent = node.parent; + while (parent !== undefined && ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + if (parent === undefined) return false; + if (ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) + return parent.expression === child; + if (ts.isForStatement(parent)) return parent.condition === child; + if (ts.isConditionalExpression(parent)) return parent.condition === child; + if (ts.isPrefixUnaryExpression(parent)) return parent.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isCallExpression(parent)) { + return ( + !parent.questionDotToken && + parent.arguments.nodes.length === 1 && + parent.arguments.nodes[0] === child && + lowerer.isStdlibGlobal(parent.expression, "Boolean") + ); + } + if (ts.isBinaryExpression(parent)) { + const op = parent.operatorToken.kind; + if (op !== ts.SyntaxKind.AmpersandAmpersandToken && op !== ts.SyntaxKind.BarBarToken) + return false; + if (isTruthinessOnlyRead(lowerer, parent)) return true; + let outer: ts.Node | undefined = parent.parent; + while (outer !== undefined && ts.isParenthesizedExpression(outer)) outer = outer.parent; + return parent.left === child && outer !== undefined && ts.isExpressionStatement(outer); + } + return false; +} + +/** Whether a program source reads process.exitCode. Such a program + * records its exit-code assignments (process.setExitCodeTracked), so a + * read can tell an unset code from 0; other programs keep the untracked + * write. Computed once per lowering pass. */ +export function programReadsExitCode(lowerer: Lowerer): boolean { + if (lowerer.readsExitCode !== null) return lowerer.readsExitCode; + let found = false; + const walk = (node: ts.Node): void => { + if (found) return; + if ( + ts.isPropertyAccessExpression(node) && + node.name.text === "exitCode" && + !( + ts.isBinaryExpression(node.parent) && + node.parent.left === node && + node.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) && + lowerer.stdlibGlobalMember(node, "process") === "exitCode" + ) { + found = true; + return; + } + ts.forEachChild(node, walk); + }; + for (const sf of lowerer.program.getImplementationSourceFiles()) { + if (sf.isDeclarationFile) continue; + walk(sf); + if (found) break; + } + lowerer.readsExitCode = found; + return found; +} + /** The pinned Node release the runtime reports as process.versions.node * (packages/runtime/src/scr_lib.c SCR_NODE_COMPAT_VERSION, kept equal to * internal/compatibility/node-v24.json by test/node-pin.test.ts). */ @@ -361,9 +500,45 @@ export function lowerProcessProperty( const member = lowerer.stdlibGlobalMember(expr, "process"); if (member === null) return null; const loc = locOf(expr); + // `if (process.send)`: Node defines process.send and process.disconnect + // only when the process has an IPC channel. A read whose value only + // feeds a truthiness test answers that presence; other value uses keep + // the member fence. + if ( + (member === "send" || member === "disconnect") && + !expr.questionDotToken && + isTruthinessOnlyRead(lowerer, expr) + ) { + return processHasIpc(lowerer, loc); + } if (member === "versions") { return { kind: "libCall", fn: "process.versions", args: [], type: DYN, loc }; } + // process.exitCode: undefined until something assigns it, then the + // assigned code. A read the checker narrowed to number follows an + // assignment, so the code is set. + if (member === "exitCode" && !expr.questionDotToken) { + const current: IrExpr = { + kind: "libCall", + fn: "process.currentExitCode", + args: [], + type: F64, + loc, + }; + if (lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "f64") return current; + const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, UNDEFINED_T]) }; + return { + kind: "ternary", + cond: { kind: "libCall", fn: "process.hasExitCode", args: [], type: BOOL, loc }, + then: lowerer.coerceToExpected(current, type), + else_: lowerer.coerceToExpected( + { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type, + ), + type, + loc, + }; + } // process.noDeprecation / process.throwDeprecation: the values the // program assigned (undefined before that — no Node flags reach a // compiled binary). @@ -1483,10 +1658,36 @@ export function lowerProcessMethodCall( } if (member === "exit") { const arg = call.arguments.nodes[0]; - const code: IrExpr = + let code: IrExpr = arg !== undefined ? lowerer.lowerExprExpecting(arg, F64) : { kind: "libCall", fn: "process.currentExitCode", args: [], type: F64, loc }; + // process.exit(code) assigns process.exitCode before the 'exit' + // listeners run. Only a program that reads the property can observe + // that, so only such a program records the assignment. + if (arg !== undefined && programReadsExitCode(lowerer)) { + const binding = lowerer.declareHiddenLocal("%exitCode", F64); + code = { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: binding.id, init: code, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "process.setExitCodeTracked", + args: [varRef(binding.id, F64, loc)], + type: VOID, + loc, + }, + loc, + }, + ], + result: varRef(binding.id, F64, loc), + type: F64, + loc, + }; + } return { kind: "libCall", fn: "process.exit", args: [code], type: VOID, loc }; } return null; // process.argv(...) etc. are tsc errors before lowering @@ -1502,6 +1703,16 @@ export function lowerProcessOptionalMethodCall( lowerer: Lowerer, expr: ts.CallExpression, ): IrExpr | null { + // `process.send!(message)`: the asserted direct call of the optional + // member (the @types/node spelling under strict null checks). + if ( + !expr.questionDotToken && + ts.isNonNullExpression(expr.expression) && + ts.isPropertyAccessExpression(expr.expression.expression) && + !expr.expression.expression.questionDotToken && + lowerer.stdlibGlobalMember(expr.expression.expression, "process") === "send" + ) + return lowerProcessIpcSend(lowerer, expr); if (!expr.questionDotToken) return null; if (!ts.isPropertyAccessExpression(expr.expression)) return null; const member = lowerer.stdlibGlobalMember(expr.expression, "process"); diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index 2656da18b..48106d082 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -65,6 +65,9 @@ export function lowerPromiseStaticCall( const compatible = (inner: IrType): boolean => { if (typeEquals(inner, resultT.inner)) return true; if (!resultArms) return false; + // A Promise entry fulfills with undefined, which the result + // holds when the checker folded `void` into its undefined arm. + if (inner.kind === "void") return resultArms.some((a) => a.kind === "undefinedT"); if (inner.kind === "union") { const arms = lowerer.unions.get(inner.unionId)?.arms ?? []; return arms.every((a) => resultArms.some((b) => typeEquals(a, b))); diff --git a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts new file mode 100644 index 000000000..cbb13d7f8 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts @@ -0,0 +1,357 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { varRef } from "../../../ir/build.js"; +import { + BOOL, + type IrExpr, + type IrLocal, + type IrParam, + type IrStmt, + type IrType, + type SrcLoc, + VOID, + funcOf, + isUnitType, + typeEquals, + typeKey, +} from "../../../ir/ir.js"; + +const ERROR_T: IrType = { kind: "object", className: "%Error" }; + +/** The parts of `util.promisify(fn)` for a typed callback-last function: + * `fn(...args, (err, value) => void)` becomes `(...args) => Promise`. */ +export interface CallbackShape { + fnT: IrType & { kind: "func" }; + resultT: IrType & { kind: "func" }; + args: readonly IrType[]; + callbackT: IrType & { kind: "func" }; + inner: IrType; + /** The error parameter's union and the tag of its Error arm, or null when + * the callback declares no error parameter. */ + error: { type: IrType; errorTag: number; unitTags: number[] } | null; +} + +function callbackShape( + lowerer: Lowerer, + fnT: IrType | null, + resultT: IrType | null, + loc: SrcLoc, +): CallbackShape | null { + if (fnT?.kind !== "func" || resultT?.kind !== "func") return null; + if (fnT.rest || fnT.restAbi || fnT.argumentsAll || resultT.rest || resultT.restAbi) return null; + const callbackT = fnT.params[fnT.params.length - 1]; + if (callbackT?.kind !== "func" || callbackT.ret.kind !== "void") return null; + if (callbackT.rest || callbackT.restAbi || callbackT.params.length > 2) return null; + const args = fnT.params.slice(0, -1); + if ( + resultT.ret.kind !== "promise" || + resultT.params.length !== args.length || + !resultT.params.every((type, i) => typeEquals(type, args[i]!)) + ) + return null; + const inner = resultT.ret.inner; + let error: CallbackShape["error"] = null; + const errorT = callbackT.params[0]; + if (errorT !== undefined) { + if (errorT.kind !== "union") return null; + const arms = lowerer.unions.get(errorT.unionId)?.arms ?? []; + const errorTags = arms.flatMap((arm, tag) => + arm.kind === "object" && + (arm.className === "%Error" || lowerer.isSubclassOf(arm.className, "%Error")) + ? [tag] + : [], + ); + const unitTags = arms.flatMap((arm, tag) => (isUnitType(arm) ? [tag] : [])); + if (errorTags.length !== 1 || errorTags.length + unitTags.length !== arms.length) return null; + error = { type: errorT, errorTag: errorTags[0]!, unitTags }; + } + if (inner.kind !== "void") { + const valueT = callbackT.params[1]; + if (valueT === undefined) return null; + const probe: IrExpr = { kind: "varRef", localId: "%probe", type: valueT, loc }; + if (!typeEquals(lowerer.coerceToExpected(probe, inner).type, inner)) return null; + } + return { fnT, resultT, args, callbackT, inner, error }; +} + +/** The callback shape of a `promisify(fn)` call, or null. */ +export function callbackPromisifyShape( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): CallbackShape | null { + if (call.questionDotToken || call.arguments.nodes.length !== 1) return null; + const argNode = call.arguments.nodes[0]!; + if (ts.isSpreadElement(argNode)) return null; + return callbackShape( + lowerer, + lowerer.mapTypeOf(lowerer.typeOf(argNode)), + lowerer.mapTypeOf(lowerer.typeOf(call)), + loc, + ); +} + +function captureParam(localId: string, type: IrType): IrParam { + return { localId, name: localId, type }; +} + +function boxedLocal(localId: string, type: IrType): IrLocal { + return { id: localId, name: localId, type, mutable: false, boxed: true }; +} + +function plainLocal(localId: string, type: IrType): IrLocal { + return { id: localId, name: localId, type, mutable: false }; +} + +/** `util.promisify(fn)` over a typed callback-last function value (a bound + * method, a function declaration, a closure): a native function that calls + * `fn` with the arguments and a callback, and settles its promise from the + * callback like Node's wrapper: a truthy error rejects, otherwise the first + * value fulfills. A synchronous throw rejects (the executor's rule). Null + * when the argument or result does not have that typed shape. */ +export function lowerCallbackPromisify( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr | null { + const shape = callbackPromisifyShape(lowerer, call, loc); + if (shape === null) return null; + const argNode = call.arguments.nodes[0]!; + let fn = lowerer.lowerExpr(argNode); + if (!typeEquals(fn.type, shape.fnT)) fn = lowerer.coerceToExpected(fn, shape.fnT); + if (!typeEquals(fn.type, shape.fnT)) return null; + return { + kind: "call", + callee: promisifyMaker(lowerer, shape, loc), + args: [fn], + type: shape.resultT, + loc, + }; +} + +/** Interns the helper family for one (function, result) type pair and + * answers the maker: `(fn) => closure(...args) => Promise`. */ +function promisifyMaker(lowerer: Lowerer, shape: CallbackShape, loc: SrcLoc): string { + const key = `promisify:${typeKey(shape.fnT)}=>${typeKey(shape.resultT)}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const base = `%promisify.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, base); + const { fnT, resultT, args, callbackT, inner, error } = shape; + const promiseT = resultT.ret; + const resolveT = funcOf(inner.kind === "void" ? [] : [inner], VOID); + const rejectT = funcOf([ERROR_T], VOID); + const executorT = funcOf([resolveT, rejectT], VOID); + const argIds = args.map((_, i) => `a${i}.0`); + + // The callback: (err, value) => err ? reject(err) : resolve(value). + const cbParams: IrParam[] = []; + callbackT.params.forEach((type, i) => + cbParams.push(captureParam(i === 0 ? "err.0" : "value.0", type)), + ); + const cbBody: IrStmt[] = []; + if (error !== null) { + const err = varRef("err.0", error.type, loc); + let truthy: IrExpr | null = null; + for (const tag of error.unitTags) { + const part: IrExpr = { + kind: "unionIsTag", + unionId: (error.type as IrType & { kind: "union" }).unionId, + tag, + negated: true, + value: err, + type: BOOL, + loc, + }; + truthy = + truthy === null + ? part + : { kind: "logical", op: "&&", left: truthy, right: part, type: BOOL, loc }; + } + const errorArm = lowerer.unions.get((error.type as IrType & { kind: "union" }).unionId)!.arms[ + error.errorTag + ]!; + let reason: IrExpr = { + kind: "unionNarrow", + unionId: (error.type as IrType & { kind: "union" }).unionId, + tag: error.errorTag, + value: err, + type: errorArm, + loc, + }; + if (!typeEquals(errorArm, ERROR_T)) + reason = { kind: "upcast", value: reason, type: ERROR_T, loc }; + const reject: IrStmt[] = [ + { + kind: "exprStmt", + expr: { + kind: "callValue", + callee: varRef("rej.0", rejectT, loc), + args: [reason], + type: VOID, + loc, + }, + loc, + }, + { kind: "return", value: null, loc }, + ]; + cbBody.push( + truthy === null ? reject[0]! : { kind: "if", cond: truthy, then: reject, else_: null, loc }, + ); + if (truthy === null) cbBody.push(reject[1]!); + } + const value = + inner.kind === "void" + ? [] + : [lowerer.coerceToExpected(varRef("value.0", callbackT.params[1]!, loc), inner)]; + cbBody.push({ + kind: "exprStmt", + expr: { + kind: "callValue", + callee: varRef("res.0", resolveT, loc), + args: value, + type: VOID, + loc, + }, + loc, + }); + lowerer.liftedFns.push({ + name: `${base}.cb`, + params: cbParams, + returnType: VOID, + captures: [captureParam("res.0", resolveT), captureParam("rej.0", rejectT)], + locals: [ + boxedLocal("res.0", resolveT), + boxedLocal("rej.0", rejectT), + ...cbParams.map((p) => plainLocal(p.localId, p.type)), + ], + body: cbBody, + loc, + }); + lowerer.liftedFns.push({ + name: `${base}.mkcb`, + params: [captureParam("res.0", resolveT), captureParam("rej.0", rejectT)], + returnType: callbackT, + locals: [boxedLocal("res.0", resolveT), boxedLocal("rej.0", rejectT)], + body: [ + { + kind: "return", + value: { + kind: "closure", + fnName: `${base}.cb`, + captures: ["res.0", "rej.0"], + type: callbackT, + loc, + }, + loc, + }, + ], + loc, + }); + + // The executor: (resolve, reject) => fn(...args, callback). + const captured: IrParam[] = [captureParam("f.0", fnT)]; + args.forEach((type, i) => captured.push(captureParam(argIds[i]!, type))); + lowerer.liftedFns.push({ + name: `${base}.exec`, + params: [captureParam("res.0", resolveT), captureParam("rej.0", rejectT)], + returnType: VOID, + captures: captured, + locals: [ + ...captured.map((p) => boxedLocal(p.localId, p.type)), + plainLocal("res.0", resolveT), + plainLocal("rej.0", rejectT), + ], + body: [ + { + kind: "exprStmt", + expr: { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: [ + ...args.map((type, i) => varRef(argIds[i]!, type, loc)), + { + kind: "call", + callee: `${base}.mkcb`, + args: [varRef("res.0", resolveT, loc), varRef("rej.0", rejectT, loc)], + type: callbackT, + loc, + }, + ], + type: VOID, + loc, + }, + loc, + }, + ], + loc, + }); + lowerer.liftedFns.push({ + name: `${base}.mkexec`, + params: captured, + returnType: executorT, + locals: captured.map((p) => boxedLocal(p.localId, p.type)), + body: [ + { + kind: "return", + value: { + kind: "closure", + fnName: `${base}.exec`, + captures: captured.map((p) => p.localId), + type: executorT, + loc, + }, + loc, + }, + ], + loc, + }); + + // The promisified function: (...args) => new Promise(executor). + const implParams: IrParam[] = []; + args.forEach((type, i) => implParams.push(captureParam(argIds[i]!, type))); + lowerer.liftedFns.push({ + name: `${base}.impl`, + params: implParams, + returnType: promiseT, + captures: [captureParam("f.0", fnT)], + locals: [boxedLocal("f.0", fnT), ...implParams.map((p) => plainLocal(p.localId, p.type))], + body: [ + { + kind: "return", + value: { + kind: "newPromise", + executor: { + kind: "call", + callee: `${base}.mkexec`, + args: [ + varRef("f.0", fnT, loc), + ...implParams.map((p) => varRef(p.localId, p.type, loc)), + ], + type: executorT, + loc, + }, + type: promiseT, + loc, + }, + loc, + }, + ], + loc, + }); + lowerer.liftedFns.push({ + name: base, + params: [captureParam("f.0", fnT)], + returnType: resultT, + locals: [boxedLocal("f.0", fnT)], + body: [ + { + kind: "return", + value: { kind: "closure", fnName: `${base}.impl`, captures: ["f.0"], type: resultT, loc }, + loc, + }, + ], + loc, + }); + return base; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 18f17f390..99ee53547 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -6,7 +6,7 @@ import { lowerEventObjectProperty, lowerEventObjectPropertyWrite } from "./lower import { lowerDisposableStackInstanceOf } from "./builtins/disposable-stack.js"; import { lowerEventStaticProperty } from "./builtins/numbers.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; -import { isOptionalProcessStreamProperty } from "./builtins/process.js"; +import { isOptionalProcessStreamProperty, processHasIpc } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; import { esmImportBindingKind } from "./builtins/module-bindings.js"; import { lowerReadlineProperty } from "./builtins/readline.js"; @@ -644,6 +644,24 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (webAssembly) return webAssembly; if (lowerer.isStdlibGlobal(expr.expression, "process")) return { kind: "strLit", value: "object", type: STRING, loc }; + { + let operand = expr.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + const ipcMember = + ts.isPropertyAccessExpression(operand) && !operand.questionDotToken + ? lowerer.stdlibGlobalMember(operand, "process") + : null; + if (ipcMember === "send" || ipcMember === "disconnect") { + return { + kind: "ternary", + cond: processHasIpc(lowerer, loc), + then: { kind: "strLit", value: "function", type: STRING, loc }, + else_: { kind: "strLit", value: "undefined", type: STRING, loc }, + type: STRING, + loc, + }; + } + } if (lowerer.isStdlibGlobal(expr.expression, "fetch")) return { kind: "strLit", value: "function", type: STRING, loc }; const performance = lowerPerfHooksTypeof(lowerer, expr.expression); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 5b6eb0d5b..8e8f9eee8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -15,7 +15,7 @@ import { InternalCompilerError } from "../../errors.js"; * flow (if/while/for/for-of/do, switch, try/catch, abrupt completions), * explicit resource management, and blocked-binding poisoning. */ import * as ts from "../ts7/adapter.js"; -import { processDataMemberValue } from "./builtins/process.js"; +import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; import { bindingSource } from "../binding-source.js"; @@ -7129,9 +7129,12 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm ) { const loc = locOf(expr); const value = lowerer.lowerExprExpecting(expr.right, F64); + const fn = programReadsExitCode(lowerer) + ? "process.setExitCodeTracked" + : "process.setExitCode"; return { kind: "exprStmt", - expr: { kind: "libCall", fn: "process.setExitCode", args: [value], type: VOID, loc }, + expr: { kind: "libCall", fn, args: [value], type: VOID, loc }, loc, }; } diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 7639e0974..12aa60f9d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -999,6 +999,15 @@ export function lowerStreamNew(lowerer: Lowerer, expr: ts.NewExpression, info: C }; } +/** True when a value of Node's argument type `passed` widens implicitly + * into the declared parameter type `declared` (one of its union arms, or a + * sub-union of it). */ +function widensInto(lowerer: Lowerer, passed: IrType, declared: IrType, loc: SrcLoc): boolean { + if (declared.kind !== "union") return false; + const probe: IrExpr = { kind: "varRef", localId: "%probe", type: passed, loc }; + return typeEquals(lowerer.coerceToExpected(probe, declared).type, declared); +} + /* ── user subclasses (`extends Readable`, the underscore-override form) ── */ /** The wrapper closure binding one overridden underscore method as the @@ -1075,10 +1084,17 @@ function streamMethodWrapper( ); } } else if (!typeEquals(got.type, want)) { + // A declared supertype of what Node passes (`chunk: Buffer | string`) + // is sound: the wrapper takes Node's value and widens it into the + // declared parameter. + if (widensInto(lowerer, want, got.type, loc)) { + paramTypes.push(want); + continue; + } lowerer.noLowering( `a '${methodName}' method whose parameter ${i} is '${lowerer.fmt(got.type)}' where Node passes '${lowerer.fmt(want)}'`, blame, - "annotate the parameter with the Node signature's type (chunks are Buffers here)", + "annotate the parameter with the Node signature's type or a supertype of it (chunks are Buffers here)", ); } paramTypes.push(got.type); @@ -1094,7 +1110,11 @@ function streamMethodWrapper( for (let i = 0; i < paramTypes.length; i++) { const local = lowerer.declareHiddenLocal(`a${i}`, paramTypes[i]!); params.push({ localId: local.id, name: local.name, type: paramTypes[i]! }); - argRefs.push({ kind: "varRef", localId: local.id, type: paramTypes[i]!, loc }); + const arg: IrExpr = { kind: "varRef", localId: local.id, type: paramTypes[i]!, loc }; + const declaredType = declared[i]!.type; + argRefs.push( + typeEquals(arg.type, declaredType) ? arg : lowerer.coerceToExpected(arg, declaredType), + ); } const thisRef: IrExpr = { kind: "varRef", localId: thisLocal.id, type: thisType, loc }; let call: IrExpr; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c5752340c..4dccca764 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1960,6 +1960,8 @@ export class Lowerer { readonly registeredClassSymbols = new Map(); readonly httpClientFnBindings = new Map(); streamPropMutatedSyms: Set | null = null; + /** Whether some source reads process.exitCode (processReadsExitCode). */ + readsExitCode: boolean | null = null; requestInitPropMutatedSyms: Set | null = null; emitterEventTable: Map | null = null; emitterComputedPatterns: ComputedEventPattern[] = []; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index be202dfe3..7ed5bbf23 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2625,8 +2625,8 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ id: "node-builtin.process.exitCode", kind: "node-builtin", name: "process.exitCode", - fns: ["process.setExitCode"], - note: "numeric writes in statement position set the implicit exit status; process.exit() reads it", + fns: ["process.setExitCode", "process.setExitCodeTracked", "process.hasExitCode"], + note: "numeric writes in statement position set the implicit exit status; reads answer the assigned code or undefined; process.exit() reads it", }, // ── the tls CA store (lowerTlsCaCall / lowerTlsRootCertificates): the // host's trust anchors, read and replaced. Dedicated paths, and @@ -2938,7 +2938,7 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record< }, util: { promisify: - "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions, dns.lookup, dns.lookupService and the fs callback functions with an fs/promises counterpart; call the bound function directly", + "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions, dns.lookup, dns.lookupService and the fs callback functions with an fs/promises counterpart, plus typed callback-last function values whose callback takes (error?: Error | null, value?); call the bound function directly", }, "stream/consumers": { blob: diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 123a5d1a0..940b1eb96 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -125,6 +125,14 @@ export function jsOpenObjectType( : type; } +/** The modules whose RequestOptions interface maps to the checked dynamic. */ +const HTTP_REQUEST_OPTION_MODULES: ReadonlySet = new Set([ + "http", + "node:http", + "https", + "node:https", +]); + export const FETCH_AMBIENT_TYPES = [ "Response", "Request", @@ -2443,6 +2451,21 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // spawn consumes a presence-sensitive option bag, including optional // stdio and env values. Keep it checked-dynamic for native normalization. if (isStdlibInterface("SpawnOptions")) return DYN; + // http.RequestOptions / https.RequestOptions as a declared type: an + // option record whose members include functions, agents and signals. + // Values ride checked-dynamic storage, like SpawnOptions. + if ( + psym?.name === "RequestOptions" && + checker.declarationsOf(psym).some((d) => { + if (!ts.isInterfaceDeclaration(d) || !ctx.isStdlibFile(d.getSourceFile())) return false; + for (let p: ts.Node | undefined = d.parent; p !== undefined; p = p.parent) { + if (ts.isModuleDeclaration(p) && ts.isStringLiteral(p.name)) + return HTTP_REQUEST_OPTION_MODULES.has(p.name.text); + } + return false; + }) + ) + return DYN; // exactly like npm-declared types. Static fetch has one deliberately // narrower native representation: Response is an opaque checked- diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 20dc82555..f972245ff 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -346,6 +346,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "process.kill", "process.killNum", "process.setExitCode", + "process.setExitCodeTracked", // Node's argument grammar throws ERR_INVALID_ARG_TYPE at the call. "process.emitWarning", // cpuUsage(prev)'s field validation: negative/non-finite prev fields diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 028b12c21..74e868124 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2390,6 +2390,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** Rest listeners use the checked function bridge to pack event arguments. */ "child.onDyn": { argTypes: [CHILD_T, STRING, DYN], result: VOID }, "process.connected": { argTypes: [], result: BOOL }, + /** Whether this fork child opened its channel; part of process.send's + * presence in programs with fork targets. Never throws. */ + "process.ipcOpen": { argTypes: [], result: BOOL }, "process.send": { argTypes: [STRING], result: BOOL }, "process.sendCb": { argTypes: [STRING, null], result: BOOL }, "process.disconnect": { argTypes: [], result: VOID }, @@ -3016,8 +3019,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "process.exit": { argTypes: [F64], result: VOID }, /** Numeric process.exitCode write (integer validation, implicit exit status). */ "process.setExitCode": { argTypes: [F64], result: VOID }, + /** The process.exitCode write of a program that also reads the + * property: the same validation, and the code counts as assigned. */ + "process.setExitCodeTracked": { argTypes: [F64], result: VOID }, /** The code for process.exit() with no argument, or zero when unset. */ "process.currentExitCode": { argTypes: [], result: F64 }, + /** Whether process.exitCode reads a code rather than undefined. Never throws. */ + "process.hasExitCode": { argTypes: [], result: BOOL }, "process.cwd": { argTypes: [], result: STRING }, /** getpid(2) / getuid(2): zero args → f64. POSIX-only target, so both * always answer (the checker's `getuid?` optionality covers Windows — diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 870ca14f0..8a9216f58 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -250,6 +250,7 @@ const CHILD_PROCESS_LIB_FNS: ReadonlySet = new Set([ "process.connected", "process.disconnect", "process.forkTarget", + "process.ipcOpen", "process.onDisconnect", "process.onMessage", "process.send", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ab1c3405e..6b77c7581 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1988,7 +1988,7 @@ "kind": "node-builtin", "name": "process.exitCode", "status": "static", - "note": "numeric writes in statement position set the implicit exit status; process.exit() reads it" + "note": "numeric writes in statement position set the implicit exit status; reads answer the assigned code or undefined; process.exit() reads it" }, { "id": "node-builtin.process.getBuiltinModule", @@ -2532,7 +2532,7 @@ "name": "util.promisify", "status": "unsupported", "code": "SC2020", - "note": "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions, dns.lookup, dns.lookupService and the fs callback functions with an fs/promises counterpart; call the bound function directly" + "note": "the lowered shapes are const bindings over child_process.execFile, child_process.exec, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions, dns.lookup, dns.lookupService and the fs callback functions with an fs/promises counterpart, plus typed callback-last function values whose callback takes (error?: Error | null, value?); call the bound function directly" }, { "id": "node-builtin.util.stripVTControlCharacters", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-process-send-presence.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-process-send-presence.ts.json new file mode 100644 index 000000000..cee8d4a17 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-process-send-presence.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6262-process-send-presence.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-process-send-presence-fork/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-process-send-presence-fork/main.ts.json new file mode 100644 index 000000000..64435c619 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-process-send-presence-fork/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6263-process-send-presence-fork/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6264-process-exitcode-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6264-process-exitcode-read.ts.json new file mode 100644 index 000000000..014ce19b2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6264-process-exitcode-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6264-process-exitcode-read.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6265-process-exitcode-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6265-process-exitcode-default.ts.json new file mode 100644 index 000000000..6eb0692f9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6265-process-exitcode-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6265-process-exitcode-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6266-process-exit-keeps-exitcode.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6266-process-exit-keeps-exitcode.ts.json new file mode 100644 index 000000000..6694c8045 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6266-process-exit-keeps-exitcode.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6266-process-exit-keeps-exitcode.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6267-stream-hook-wider-chunk.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6267-stream-hook-wider-chunk.ts.json new file mode 100644 index 000000000..13c0a961d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6267-stream-hook-wider-chunk.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6267-stream-hook-wider-chunk.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6268-promise-race-void-entry.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6268-promise-race-void-entry.ts.json new file mode 100644 index 000000000..0e5a4fb75 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6268-promise-race-void-entry.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6268-promise-race-void-entry.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6269-http-request-options-type.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6269-http-request-options-type.ts.json new file mode 100644 index 000000000..c61e5dec5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6269-http-request-options-type.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6269-http-request-options-type.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6270-promisify-callback-functions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6270-promisify-callback-functions.ts.json new file mode 100644 index 000000000..2bcb2df90 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6270-promisify-callback-functions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6270-promisify-callback-functions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/boot-path-node-surface-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/boot-path-node-surface-fences.ts.json new file mode 100644 index 000000000..de58c3423 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/boot-path-node-surface-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/boot-path-node-surface-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/promisify-callback-functions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/promisify-callback-functions.ts.json new file mode 100644 index 000000000..a4a753c3d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/promisify-callback-functions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/promisify-callback-functions.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 87ead5755..2537594a8 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -6406,6 +6406,7 @@ void scr_child_ipc_on_disconnect(ScrChild *c, ScrClosure *cb, bool once) { bool scr_process_ipc_connected(void) { return scr_process_ipc != NULL && scr_process_ipc->connected; } +bool scr_process_ipc_open(void) { return scr_process_ipc != NULL; } bool scr_process_ipc_send(ScrStr *json) { return scr_ipc_send(scr_process_ipc, json, NULL, NULL); } diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index 1187985c8..3159569aa 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -182,6 +182,9 @@ void scr_child_ipc_on_disconnect(ScrChild *c, ScrClosure *cb /*moves*/, bool once); double scr_process_fork_target(double target_count); bool scr_process_ipc_connected(void); +/* Whether this fork child opened its channel (process.send's presence in + * a program with fork targets; disconnecting keeps it). */ +bool scr_process_ipc_open(void); bool scr_process_ipc_send(ScrStr *json); bool scr_process_ipc_send_cb(ScrStr *json, ScrClosure *cb /*moves*/, ScrIpcSendFn fn); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 930d44818..3eef75750 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -2037,6 +2037,19 @@ double scr_process_exit_code_or_zero(void) { return (double)scr_exit_code_hint_get(); } +static SCR_TL bool scr_exit_code_assigned = false; + +void scr_process_set_exit_code_tracked(double code) { + int converted; + if (!scr_process_exit_code_of(code, &converted)) return; + scr_exit_code_note(converted); + scr_exit_code_assigned = true; +} + +bool scr_process_has_exit_code(void) { + return scr_exit_code_assigned || scr_exit_code_hint_get() != 0; +} + #if defined(__linux__) #include #include diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 4c973e3e5..690f84875 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -149,6 +149,11 @@ void scr_process_exit(double code); extern const unsigned char scr_detached_exit; void scr_process_set_exit_code(double code); double scr_process_exit_code_or_zero(void); +/* The writer and the presence test of programs that read process.exitCode: + * the code reads undefined until an assignment (or a nonzero exit status + * the runtime chose, such as an uncaught exception's 1). */ +void scr_process_set_exit_code_tracked(double code); +bool scr_process_has_exit_code(void); /* process._exiting: true once the exit sequence began (process.exit or * the exit-listener runner set the flag). Never throws. */ extern SCR_TL bool scr_process_in_exit; diff --git a/tests/corpus/6262-process-send-presence.ts b/tests/corpus/6262-process-send-presence.ts new file mode 100644 index 000000000..59773ab44 --- /dev/null +++ b/tests/corpus/6262-process-send-presence.ts @@ -0,0 +1,24 @@ +// process.send exists only with an IPC channel: presence checks in every +// truthiness position, typeof, and guarded calls. +const viaIpc = Boolean(process.send); +console.log("Boolean", viaIpc); +console.log("not", !process.send, !!process.disconnect); +console.log("typeof", typeof process.send, typeof process.disconnect); +console.log("ternary", process.send ? "ipc" : "stdout"); +if (process.send) { + process.send({ kind: "log", line: "never" }); +} else { + console.log("if: no channel"); +} +let polls = 0; +while (process.send && polls < 3) polls++; +console.log("while", polls); +process.send && process.send({ unused: true }); +const sink = !process.send || !process.disconnect ? "console" : "ipc"; +console.log("or", sink); +console.log("optional", process.send?.({ value: 1 })); +try { + process.send!({ value: 2 }); +} catch (e) { + console.log("direct", (e as Error).name, (e as Error).message); +} diff --git a/tests/corpus/6263-process-send-presence-fork/main.ts b/tests/corpus/6263-process-send-presence-fork/main.ts new file mode 100644 index 000000000..11a634672 --- /dev/null +++ b/tests/corpus/6263-process-send-presence-fork/main.ts @@ -0,0 +1,13 @@ +// process.send and process.disconnect exist in a forked child (an IPC +// channel) and not in the parent; the child's presence checks pick the +// channel and its guarded send reaches the parent. +import { fork } from "node:child_process"; + +console.log("parent", Boolean(process.send), typeof process.send, typeof process.disconnect); +const child = fork(new URL("./worker.ts", import.meta.url), [], { + stdio: ["ignore", "inherit", "inherit", "ipc"], +}); +child.on("message", (message: { sink: string; send: string; disconnect: string }) => { + console.log("child says", message.sink, message.send, message.disconnect); +}); +child.on("exit", (code) => console.log("child exit", code)); diff --git a/tests/corpus/6263-process-send-presence-fork/worker.ts b/tests/corpus/6263-process-send-presence-fork/worker.ts new file mode 100644 index 000000000..81e8288a7 --- /dev/null +++ b/tests/corpus/6263-process-send-presence-fork/worker.ts @@ -0,0 +1,6 @@ +const sink = process.send ? "ipc" : "stdout"; +if (process.send) { + process.send({ sink, send: typeof process.send, disconnect: typeof process.disconnect }); +} +if (!process.send) console.log("unreachable"); +process.disconnect(); diff --git a/tests/corpus/6264-process-exitcode-read.ts b/tests/corpus/6264-process-exitcode-read.ts new file mode 100644 index 000000000..c320b4a0d --- /dev/null +++ b/tests/corpus/6264-process-exitcode-read.ts @@ -0,0 +1,22 @@ +// @exit: 7 +// process.exitCode: unset reads undefined, assignments (including 0) read back, +// and process.exit(n) assigns the code before 'exit' listeners run. +console.log("initial", process.exitCode); +process.on("exit", (code) => { + console.log("exit listener", code, process.exitCode); +}); +process.exitCode = 0; +console.log("after 0", process.exitCode); +process.exitCode = 4; +const seen = process.exitCode; +console.log("after 4", seen, typeof process.exitCode); +process.exitCode = process.exitCode ?? 9; +console.log("kept", process.exitCode); +try { + process.exitCode = 1.5; +} catch (e) { + console.log("rejected", (e as Error).message, process.exitCode); +} +setTimeout(() => { + process.exit(7); +}, 1); diff --git a/tests/corpus/6265-process-exitcode-default.ts b/tests/corpus/6265-process-exitcode-default.ts new file mode 100644 index 000000000..5dbc72294 --- /dev/null +++ b/tests/corpus/6265-process-exitcode-default.ts @@ -0,0 +1,10 @@ +// @exit: 2 +// The exit status follows process.exitCode when the program ends normally. +function settle(failed: boolean): void { + if (failed) process.exitCode = process.exitCode ?? 2; +} +console.log("start", process.exitCode === undefined); +settle(true); +settle(true); +console.log("code", process.exitCode); +process.on("exit", () => console.log("exiting with", process.exitCode)); diff --git a/tests/corpus/6266-process-exit-keeps-exitcode.ts b/tests/corpus/6266-process-exit-keeps-exitcode.ts new file mode 100644 index 000000000..e5a0b86d2 --- /dev/null +++ b/tests/corpus/6266-process-exit-keeps-exitcode.ts @@ -0,0 +1,5 @@ +// @exit: 5 +// process.exit() without an argument keeps process.exitCode. +process.exitCode = 5; +process.on("exit", (code) => console.log("exit", code, process.exitCode)); +process.exit(); diff --git a/tests/corpus/6267-stream-hook-wider-chunk.ts b/tests/corpus/6267-stream-hook-wider-chunk.ts new file mode 100644 index 000000000..be46b18be --- /dev/null +++ b/tests/corpus/6267-stream-hook-wider-chunk.ts @@ -0,0 +1,46 @@ +// Stream hooks may declare a supertype of what Node passes: chunks typed +// Buffer | string still arrive as Buffers. +import { Transform, Writable } from "node:stream"; + +class LineSink extends Writable { + private rest = ""; + readonly lines: string[] = []; + _write(chunk: Buffer | string, encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log("chunk", typeof chunk, Buffer.isBuffer(chunk), encoding); + const parts = (this.rest + chunk.toString()).split("\n"); + this.rest = parts.pop() ?? ""; + this.lines.push(...parts); + cb(); + } + _final(cb: (err?: Error | null) => void): void { + if (this.rest !== "") this.lines.push(this.rest); + cb(); + } +} + +class Upper extends Transform { + _transform(chunk: string | Buffer, _enc: string, cb: (err?: Error | null, data?: string) => void): void { + cb(null, chunk.toString().toUpperCase()); + } +} + +class Failing extends Writable { + _write(chunk: Uint8Array | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void { + cb(new Error(`refused ${chunk.toString().length} bytes`)); + } +} + +const sink = new LineSink(); +sink.write("a\nb"); +sink.write(Buffer.from("c\nd")); +sink.end("e"); +sink.on("finish", () => console.log("lines", JSON.stringify(sink.lines))); + +const up = new Upper(); +up.on("data", (d: Buffer) => console.log("upper", d.toString())); +up.write("hello "); +up.end("world"); + +const bad = new Failing(); +bad.on("error", (e) => console.log("error", e.message)); +bad.write("xyz"); diff --git a/tests/corpus/6268-promise-race-void-entry.ts b/tests/corpus/6268-promise-race-void-entry.ts new file mode 100644 index 000000000..b33f3b664 --- /dev/null +++ b/tests/corpus/6268-promise-race-void-entry.ts @@ -0,0 +1,31 @@ +// Promise.race over Promise and Promise: the void +// entry fulfills the race with undefined. +interface Msg { id: string } +function delay(ms: number, value: T): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} +function poll(ms: number, id: string | null): Promise { + return delay(ms, id === null ? undefined : { id }); +} +function exited(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +async function main(): Promise { + const a = await Promise.race([poll(5, "m1"), exited(50)]); + console.log("message first:", a ? a.id : "none"); + const b = await Promise.race([poll(50, "m2"), exited(5)]); + console.log("exit first:", b === undefined, b ? b.id : "none"); + const c = await Promise.race([exited(5), poll(1, null)]); + console.log("empty poll first:", c); + const settled = Promise.resolve(); + const d = await Promise.race([settled, poll(0, "m3")]); + console.log("settled void entry wins:", d); + const n = await Promise.race([delay(20, 42), exited(1)]); + console.log("number or void:", n); + try { + await Promise.race([poll(20, "late"), Promise.reject(new Error("boom"))]); + } catch (e) { + console.log("rejected:", (e as Error).message); + } +} +void main(); diff --git a/tests/corpus/6269-http-request-options-type.ts b/tests/corpus/6269-http-request-options-type.ts new file mode 100644 index 000000000..17cb2d855 --- /dev/null +++ b/tests/corpus/6269-http-request-options-type.ts @@ -0,0 +1,10 @@ +// http.RequestOptions as a declared type: a checked-dynamic option record. +import type http from "node:http"; +const socket = process.env.NO_SUCH_SOCKET_VAR; +const base: http.RequestOptions = socket ? { socketPath: socket } : { host: "127.0.0.1", port: 8080 }; +const full: http.RequestOptions = { ...base, path: "/x", method: "POST", headers: { "x-a": "1" } }; +console.log(JSON.stringify(base), JSON.stringify(full)); +function describe(o: http.RequestOptions): string { + return `${String(o.host)}:${String(o.port)}${String(o.path ?? "/")}`; +} +console.log(describe(full), describe({ host: "h" })); diff --git a/tests/corpus/6270-promisify-callback-functions.ts b/tests/corpus/6270-promisify-callback-functions.ts new file mode 100644 index 000000000..9c2193882 --- /dev/null +++ b/tests/corpus/6270-promisify-callback-functions.ts @@ -0,0 +1,65 @@ +// util.promisify over typed callback-only functions (a bound method, a +// function declaration, an inline arrow): the error argument rejects, the +// value fulfills, a synchronous throw rejects, and settlement is async. +import { promisify } from "node:util"; + +class Connection { + private closed = false; + private readonly name: string; + constructor(name: string) { + this.name = name; + } + close(callback: (error?: Error) => void): void { + setTimeout(() => { + if (this.closed) { + callback(new Error(`${this.name} already closed`)); + return; + } + this.closed = true; + callback(); + }, 1); + } + stats(callback: (error: Error | null, open: number) => void): void { + setImmediate(() => (this.closed ? callback(new RangeError("closed"), 0) : callback(null, 3))); + } +} + +function boom(callback: (error: Error | null) => void): void { + if (callback !== undefined) throw new TypeError("synchronous failure"); +} + +async function main(): Promise { + const c = new Connection("db"); + const stats = promisify(c.stats.bind(c)); + console.log("open", await stats()); + const closeAsync = promisify(c.close.bind(c)); + await closeAsync(); + console.log("closed once"); + try { + await closeAsync(); + } catch (e) { + console.log("second close:", (e as Error).message); + } + try { + await stats(); + } catch (e) { + console.log("stats:", (e as Error).name, (e as Error).message); + } + try { + await promisify(boom)(); + } catch (e) { + console.log("sync throw:", (e as Error).name, (e as Error).message); + } + const order: string[] = []; + const tick = promisify((done: (error?: Error) => void) => { + order.push("called"); + done(); + }); + const p = tick().then(() => order.push("settled")); + order.push("returned"); + await p; + console.log(order.join(", ")); + await promisify(new Connection("inline").close.bind(new Connection("inline")))(); + console.log("inline done"); +} +void main(); diff --git a/tests/diagnostics/boot-path-node-surface-fences.ts b/tests/diagnostics/boot-path-node-surface-fences.ts new file mode 100644 index 000000000..146a32aa1 --- /dev/null +++ b/tests/diagnostics/boot-path-node-surface-fences.ts @@ -0,0 +1,23 @@ +// The boot-path Node surface keeps its boundaries: process.send answers +// presence checks but stays fenced as a stored value, a stream hook must +// declare Node's argument type or a supertype of it, and util.promisify +// over a function value needs an Error-typed callback error. +import { promisify } from "node:util"; +import { Writable } from "node:stream"; + +const send = process.send; +console.log(typeof send); + +class Counter extends Writable { + _write(chunk: number, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log(chunk); + cb(); + } +} +new Counter().end("x"); + +function legacy(cb: (code: number, value: string) => void): void { + cb(0, "v"); +} +const legacyAsync = promisify(legacy); +legacyAsync().then((v) => console.log(v)); diff --git a/tests/fixtures/node-types/promisify-callback-functions.ts b/tests/fixtures/node-types/promisify-callback-functions.ts new file mode 100644 index 000000000..78be5e3bd --- /dev/null +++ b/tests/fixtures/node-types/promisify-callback-functions.ts @@ -0,0 +1,67 @@ +// util.promisify over typed callback-last functions with leading arguments +// under @types/node: bound methods, a function declaration, rejection from +// the callback error, a synchronous throw, and asynchronous settlement. +import { promisify } from "node:util"; + +class Client { + private closed = false; + private readonly name: string; + constructor(name: string) { + this.name = name; + } + close(callback: (error?: Error) => void): void { + setTimeout(() => { + if (this.closed) { + callback(new Error(`${this.name} already closed`)); + return; + } + this.closed = true; + callback(); + }, 1); + } + fetch(key: string, callback: (error: Error | null, value?: string) => void): void { + setImmediate(() => { + if (key === "") callback(new RangeError("empty key")); + else callback(null, `${this.name}:${key}`); + }); + } +} + +function sum(a: number, b: number, done: (err: Error | null, total: number) => void): void { + if (a < 0) throw new TypeError("negative"); + process.nextTick(() => done(null, a + b)); +} + +async function main(): Promise { + const c = new Client("db"); + const closeAsync = promisify(c.close.bind(c)); + await closeAsync(); + console.log("closed once"); + try { + await closeAsync(); + } catch (e) { + console.log("second close:", (e as Error).message); + } + const fetchAsync = promisify(c.fetch.bind(c)); + console.log(await fetchAsync("k")); + try { + await fetchAsync(""); + } catch (e) { + console.log("fetch:", (e as Error).name, (e as Error).message); + } + const sumAsync = promisify(sum); + console.log("sum", await sumAsync(2, 3)); + try { + await sumAsync(-1, 3); + } catch (e) { + console.log("sync throw:", (e as Error).name, (e as Error).message); + } + await promisify(new Client("inline").close.bind(new Client("inline")))(); + console.log("inline done"); + const order: string[] = []; + const p = sumAsync(1, 1).then((v) => order.push(`then ${v}`)); + order.push("after call"); + await p; + console.log(order.join(", ")); +} +void main(); diff --git a/tests/harness/__snapshots__/boot-path-node-surface-fences.ts.txt b/tests/harness/__snapshots__/boot-path-node-surface-fences.ts.txt new file mode 100644 index 000000000..c287a1641 --- /dev/null +++ b/tests/harness/__snapshots__/boot-path-node-surface-fences.ts.txt @@ -0,0 +1,35 @@ +boot-path-node-surface-fences.ts:8:14 - error SC2020: 'process.send' is part of the standard library types but has no scriptc lowering yet + + 7 | + 8 | const send = process.send; + | ^~~~~~~~~~~~ + 9 | console.log(typeof send); + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +boot-path-node-surface-fences.ts:11:1 - error SC2020: 'a '_write' method whose parameter 0 is 'number' where Node passes 'Uint8Array'' is part of the standard library types but has no scriptc lowering yet + + 10 | + 11 | class Counter extends Writable { + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 12 | _write(chunk: number, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + + hint: annotate the parameter with the Node signature's type or a supertype of it (chunks are Buffers here) + +boot-path-node-surface-fences.ts:22:31 - error SC2020: 'util.promisify of this target' is part of the standard library types but has no scriptc lowering yet + + 21 | } + 22 | const legacyAsync = promisify(legacy); + | ^~~~~~ + 23 | legacyAsync().then((v) => console.log(v)); + + hint: child_process.exec, child_process.execFile, setTimeout, setImmediate, stream.finished, stream.pipeline, the zlib callback one-shots, the crypto callback functions (randomBytes, pbkdf2, scrypt, hkdf, randomFill, randomInt, generateKey), dns.lookup, dns.lookupService, the fs callback functions with an fs/promises counterpart, and typed callback-last functions whose callback takes (error?: Error | null, value?) are the supported targets + +boot-path-node-surface-fences.ts:23:1 - error SC2004: uses of 'legacyAsync' inherit the blocker on its declaration + + 22 | const legacyAsync = promisify(legacy); + 23 | legacyAsync().then((v) => console.log(v)); + | ^~~~~~~~~~~ + 24 | + + hint: the declaration of 'legacyAsync' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index bf57a830a..8069ccadd 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -404,6 +404,24 @@ test("node-types: promisified fs callback functions lower as their fs/promises c expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: util.promisify over typed callback-last functions settles like Node", async () => { + const outDir = outDirFor("promisify-callback-functions"); + const entry = join(nodeTypesDir, "promisify-callback-functions.ts"); + const result = await compile(entry, { + outPath: join(outDir, "promisify-callback-functions"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: promisified setTimeout and setImmediate lower as timers/promises", async () => { const outDir = outDirFor("promisify-timers"); const entry = join(nodeTypesDir, "promisify-timers.ts"); From d9d3dc251778d011a8849e93fb7dfacbbc0ce419 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 15:21:04 -0700 Subject: [PATCH 35/85] Keep __dirname and __filename in TypeScript files that load as ES modules A .ts or .tsx file with import syntax is an ES module under Node's strip-types rule, which has no __dirname or __filename. TypeScript projects are commonly shipped as CommonJS bundles where both exist, and @types/node declares them for every file. scriptc now compiles them in such files to the module's source path, the same compile-time constants as in CommonJS and the same strings as import.meta.dirname and import.meta.filename. JavaScript ES modules keep the fence. The limitations page documents the difference. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 4 ++ .../limitations/module-path-globals.mdx | 1 + .../src/frontend/lowering/lower-exprs.ts | 10 +++-- .../tests/corpus/6271-esm-js-dirname.mjs.json | 6 +++ tests/corpus/6271-esm-js-dirname.mjs | 9 +++++ tests/fixtures/esm-path-globals/lib/helper.ts | 2 + tests/fixtures/esm-path-globals/main.ts | 12 ++++++ tests/harness/esm-path-globals.test.ts | 38 +++++++++++++++++++ 8 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 docs/content/limitations/module-path-globals.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6271-esm-js-dirname.mjs.json create mode 100644 tests/corpus/6271-esm-js-dirname.mjs create mode 100644 tests/fixtures/esm-path-globals/lib/helper.ts create mode 100644 tests/fixtures/esm-path-globals/main.ts create mode 100644 tests/harness/esm-path-globals.test.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index d261b6385..c64c5cebd 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -228,6 +228,10 @@ title: "Limitations" ../limitations/process-arguments-and-errors.mdx +### Module path globals + +../limitations/module-path-globals.mdx + ## Runtime code evaluation diff --git a/docs/content/limitations/module-path-globals.mdx b/docs/content/limitations/module-path-globals.mdx new file mode 100644 index 000000000..4c888fd32 --- /dev/null +++ b/docs/content/limitations/module-path-globals.mdx @@ -0,0 +1 @@ +`__dirname` and `__filename` are compile-time constants: the absolute path of the module's source directory and file at build time, the same strings as `import.meta.dirname` and `import.meta.filename`. A `.ts` or `.tsx` file keeps them even when it uses `import` syntax, which Node.js would load as an ES module without these globals, because TypeScript projects are commonly shipped as CommonJS bundles where they exist. In such a file `typeof __dirname` is `"string"`, where Node.js running the source directly reports `"undefined"`. JavaScript ES modules (`.mjs`, or `.js` in a `"type": "module"` package) do not have them, as in Node.js. diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 99ee53547..e20a702c8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1214,14 +1214,18 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // MODULE compile-time constants, the containing file's location // (Node's exact values when the same tree runs in place; WASI uses // the equivalent guest-visible path; locals shadowing the names - // resolved above). In a file with ESM syntax Node never defines them - // (ReferenceError) — fence rather than invent a value there. + // resolved above), the same strings as import.meta.dirname/filename. + // In a JavaScript ES module Node never defines them (ReferenceError), + // so they fence there. A .ts/.tsx file keeps them even when Node's + // strip-types rule would load it as ESM: TypeScript projects are + // commonly built as CommonJS bundles (tsc, esbuild, tsx) where these + // globals exist, and @types/node declares them for every file. if ( (expr.text === "__dirname" || expr.text === "__filename") && lowerer.isStdlibGlobal(expr, expr.text) ) { const sf = expr.getSourceFile(); - if (isNodeEsmFile(sf, lowerer.program)) { + if (isNodeEsmFile(sf, lowerer.program) && !/\.tsx?$/.test(sf.fileName)) { lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6271-esm-js-dirname.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6271-esm-js-dirname.mjs.json new file mode 100644 index 000000000..c1ea88546 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6271-esm-js-dirname.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6271-esm-js-dirname.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/6271-esm-js-dirname.mjs b/tests/corpus/6271-esm-js-dirname.mjs new file mode 100644 index 000000000..e6280e6ec --- /dev/null +++ b/tests/corpus/6271-esm-js-dirname.mjs @@ -0,0 +1,9 @@ +// A JavaScript ES module has no __dirname or __filename (Node throws a +// ReferenceError where they are read); import.meta carries the location. +// TypeScript files keep the CommonJS globals as the source path. +try { + console.log(__dirname); +} catch (error) { + console.log("no __dirname:", error instanceof Error); +} +console.log(import.meta.filename.endsWith("6271-esm-js-dirname.mjs")); diff --git a/tests/fixtures/esm-path-globals/lib/helper.ts b/tests/fixtures/esm-path-globals/lib/helper.ts new file mode 100644 index 000000000..61960c097 --- /dev/null +++ b/tests/fixtures/esm-path-globals/lib/helper.ts @@ -0,0 +1,2 @@ +export const helperDir: string = __dirname; +export const helperFile: string = __filename; diff --git a/tests/fixtures/esm-path-globals/main.ts b/tests/fixtures/esm-path-globals/main.ts new file mode 100644 index 000000000..d3d3c3b8a --- /dev/null +++ b/tests/fixtures/esm-path-globals/main.ts @@ -0,0 +1,12 @@ +// __dirname and __filename in TypeScript files with import syntax: each +// module reads its own source location, the same strings as +// import.meta.dirname and import.meta.filename. +import { basename, join } from "node:path"; +import { helperDir, helperFile } from "./lib/helper.ts"; + +console.log(typeof __dirname, typeof __filename); +console.log(basename(__dirname), basename(__filename)); +console.log(__dirname === import.meta.dirname, __filename === import.meta.filename); +console.log(join(__dirname, "lib") === helperDir, basename(helperFile)); +const home = typeof __dirname === "string" && __dirname.endsWith("/lib") ? join(__dirname, "..") : "/tmp"; +console.log(home); diff --git a/tests/harness/esm-path-globals.test.ts b/tests/harness/esm-path-globals.test.ts new file mode 100644 index 000000000..546c924a9 --- /dev/null +++ b/tests/harness/esm-path-globals.test.ts @@ -0,0 +1,38 @@ +/* __dirname / __filename in TypeScript files that Node's strip-types rule + * loads as ES modules: scriptc compiles them to the module's source path, + * the CommonJS-bundle reading. Node itself has no such globals in an ES + * module, so the oracle runs a twin of each source with the globals spelled + * import.meta.dirname / import.meta.filename, at the same paths. */ +import { execFile } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const fixture = join(import.meta.dirname, "../fixtures/esm-path-globals"); + +test("__dirname and __filename in TypeScript ES modules read the module's source path", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-esm-path-globals-")); + cpSync(fixture, join(dir, "src"), { recursive: true }); + const entry = join(dir, "src", "main.ts"); + const result = await compile(entry, { + outPath: join(dir, "main"), + outDir: dir, + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const native = await execFileAsync(result.binaryPath); + for (const file of [entry, join(dir, "src", "lib", "helper.ts")]) { + const text = readFileSync(file, "utf8") + .replaceAll("__dirname", "import.meta.dirname") + .replaceAll("__filename", "import.meta.filename"); + writeFileSync(file, text); + } + const node = await execFileAsync(process.execPath, [entry]); + expect(native.stdout).toBe(node.stdout); + expect(native.stdout).toContain("string string\nsrc main.ts\ntrue true\ntrue helper.ts\n"); +}); From ea86299fd76f20841f2c5513c70e7a1af1cb764e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 16:46:44 -0700 Subject: [PATCH 36/85] Extend the @types/node stream classes A class that extends Writable, Readable, Duplex or Transform from node:stream compiled only against the bundled Node declarations. With @types/node in the project, which nests the classes in the module's `internal` namespace, the base was refused as "not declared in the program". The recognition now accepts either declaration of the stream module's classes. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 5 +- .../node-types/stream-subclass-hooks.ts.json | 6 +++ .../node-types/stream-subclass-hooks.ts | 46 +++++++++++++++++++ tests/harness/project-config.test.ts | 18 ++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-subclass-hooks.ts.json create mode 100644 tests/fixtures/node-types/stream-subclass-hooks.ts diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 770c74840..216b21da2 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -83,7 +83,6 @@ import { isCjsJsFile, isJsSourceFile, isModuleExportsAccess, - isNodeTypesPath, locOf, } from "../program.js"; import { PoisonError, dynFallbackType, newFnCtx, own } from "./lowerer.js"; @@ -1012,9 +1011,11 @@ export function builtinStreamInfoOf( if (rec.lib === symbol.name) irName = name; } if (!irName) return null; + // Both the bundled declarations and @types/node declare the classes + // inside `declare module "stream"` (@types/node nests them in the module's + // `internal` namespace). const declared = lowerer.checker.declarationsOf(symbol).some((d) => { if (!ts.isClassDeclaration(d)) return false; - if (isNodeTypesPath(d.getSourceFile().fileName)) return false; let node: ts.Node | undefined = d.parent; while (node) { if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-subclass-hooks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-subclass-hooks.ts.json new file mode 100644 index 000000000..295d7deda --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-subclass-hooks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/stream-subclass-hooks.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/node-types/stream-subclass-hooks.ts b/tests/fixtures/node-types/stream-subclass-hooks.ts new file mode 100644 index 000000000..0e7cfbad5 --- /dev/null +++ b/tests/fixtures/node-types/stream-subclass-hooks.ts @@ -0,0 +1,46 @@ +// Stream subclasses under @types/node: a Writable whose hooks declare a +// supertype of Node's chunk type, a Transform, and an erroring Writable. +import { Transform, Writable } from "node:stream"; + +class LineSink extends Writable { + private rest = ""; + readonly lines: string[] = []; + _write(chunk: Buffer | string, encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log("chunk", typeof chunk, Buffer.isBuffer(chunk), encoding); + const parts = (this.rest + chunk.toString()).split("\n"); + this.rest = parts.pop() ?? ""; + this.lines.push(...parts); + cb(); + } + _final(cb: (err?: Error | null) => void): void { + if (this.rest !== "") this.lines.push(this.rest); + cb(); + } +} + +class Upper extends Transform { + _transform(chunk: string | Buffer, _enc: string, cb: (err?: Error | null, data?: string) => void): void { + cb(null, chunk.toString().toUpperCase()); + } +} + +class Failing extends Writable { + _write(chunk: Uint8Array | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void { + cb(new Error(`refused ${chunk.toString().length} bytes`)); + } +} + +const sink = new LineSink(); +sink.write("a\nb"); +sink.write(Buffer.from("c\nd")); +sink.end("e"); +sink.on("finish", () => console.log("lines", JSON.stringify(sink.lines))); + +const up = new Upper(); +up.on("data", (d: Buffer) => console.log("upper", d.toString())); +up.write("hello "); +up.end("world"); + +const bad = new Failing(); +bad.on("error", (e) => console.log("error", e.message)); +bad.write("xyz"); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 8069ccadd..2196e602f 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -422,6 +422,24 @@ test("node-types: util.promisify over typed callback-last functions settles like expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: stream subclasses extend the @types/node stream classes", async () => { + const outDir = outDirFor("stream-subclass-hooks"); + const entry = join(nodeTypesDir, "stream-subclass-hooks.ts"); + const result = await compile(entry, { + outPath: join(outDir, "stream-subclass-hooks"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: promisified setTimeout and setImmediate lower as timers/promises", async () => { const outDir = outDirFor("promisify-timers"); const entry = join(nodeTypesDir, "promisify-timers.ts"); From c18a4a790a46ca2d9c30295dfd57cf7a8c37fa9b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 19:07:43 -0500 Subject: [PATCH 37/85] Resolve chunk references by scope, keep shared functions out of chunk copies, and treat indirect calls as calls in cycles Three fixes for the bundled-chunk splitter and the CommonJS cycle check: - A reference counts as local only when a scope between it and the chunk factory (or the copied helper) declares its name. A nested function, block, loop or catch binding with the name of a top-level helper no longer hides the chunk's read of that helper. Before, the helper was not copied and the chunk failed at runtime on the unresolved reference. - A top-level function is copied into a chunk only when the bundle calls it and uses it in no other way. Each chunk gets its own copy, so a function that is exported, compared, constructed, or given properties would lose its shared identity. Such a chunk now keeps the package refused. - During a cycle's initialization, a read of a partner's exported function is safe only as a plain value read. A call through esbuild's `(0, binding.fn)()` spelling, `new`, a tagged template, a member of the function, or passing it to a call can run the function, so these uses keep SC1016 (Node throws when the function reads a binding in its temporal dead zone). --- .../npm-static-bundled-chunks.test.ts | 60 ++++++ .../src/frontend/npm-static-bundled-chunks.ts | 172 +++++++++++++++++- packages/compiler/src/frontend/program.ts | 21 ++- .../2564-chunk-helper-shadowing/main.ts.json | 6 + .../2565-chunk-shared-function/main.ts.json | 6 + .../2566-cjs-cycle-indirect-call/main.ts.json | 6 + .../cases/2564-chunk-helper-shadowing/main.ts | 7 + .../cases/2565-chunk-shared-function/main.ts | 7 + .../2566-cjs-cycle-indirect-call/main.ts | 7 + .../gtchunkshadow/dist/index.d.ts | 2 + .../node_modules/gtchunkshadow/dist/index.js | 52 ++++++ .../node_modules/gtchunkshadow/package.json | 7 + .../gtchunkshared/dist/index.d.ts | 2 + .../node_modules/gtchunkshared/dist/index.js | 56 ++++++ .../node_modules/gtchunkshared/package.json | 7 + .../node_modules/gtcyclecall/dist/index.d.ts | 3 + .../node_modules/gtcyclecall/dist/index.js | 37 ++++ .../node_modules/gtcyclecall/dist/reader.js | 33 ++++ .../npm/node_modules/gtcyclecall/package.json | 7 + tests/harness/npm-cases.ts | 4 +- tests/harness/npm-static.test.ts | 42 ++++- 21 files changed, 536 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json create mode 100644 tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts create mode 100644 tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts create mode 100644 tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtchunkshadow/package.json create mode 100644 tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtchunkshared/package.json create mode 100644 tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclecall/package.json diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts index 82926d2b8..fe54681d7 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -111,6 +111,66 @@ console.log(require_named().name); expect(chunk).toContain("var __defProp = Object.defineProperty;"); }); +test("a helper travels with a chunk whose nested scopes declare its name", () => { + const answer = expectSameBehavior(`${keyedHelper}function label(n) { + return "label " + n; +} +function wrap(n) { + function inner() { + var label = "inner"; + return label; + } + return label(n) + " " + inner(); +} +var require_e = __commonJS({ + "e.js"(exports2, module2) { + function nested() { + var label = "nested"; + return label; + } + var seen = []; + for (let label of ["loop"]) seen.push(label); + try { + throw "caught"; + } catch (label) { + seen.push(label); + } + { + const label = "block"; + seen.push(label); + } + module2.exports = { outer: label(1), indirect: (0, label)(3), wrapped: wrap(2), nested: nested(), seen }; + } +}); +var e = require_e(); +console.log(e.outer, e.indirect, e.wrapped, e.nested, e.seen.join(",")); +`); + const chunk = answer.chunks[0]!.source; + expect(chunk).toContain("function label(n)"); + expect(chunk).toContain("function wrap(n)"); +}); + +test.each([ + ["exports it", `function tally() {}`, `module2.exports = { tally };`], + ["reads its properties", `function tally() {}`, `exports2.calls = tally.calls;`], + ["constructs it", `function tally() {}`, `exports2.made = new tally();`], + ["stores an arrow helper", `var tally = () => 1;`, `exports2.fn = tally;`], +])("a chunk that %s keeps the package refused", (_, declaration, statement) => { + const result = split(`${keyedHelper}${declaration} +var require_f = __commonJS({ + "f.js"(exports2, module2) { + ${statement} + } +}); +require_f(); +`); + expect(result).toEqual({ + degrade: expect.stringContaining( + "uses the bundle's top-level function 'tally' as a value", + ) as string, + }); +}); + test.each([ ["reads its factory's receiver", `console.log(this);`], ["reads its factory's arguments object", `console.log(arguments.length);`], diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index cdb9dff64..abc86bcbd 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -105,6 +105,119 @@ function declaredNames(root: ts.Node): Set { return names; } +/** Adds the identifiers a binding name (an identifier or a destructuring + * pattern) declares. */ +function addBindingNames(name: ts.BindingName, out: Set): void { + if (ts.isIdentifier(name)) { + out.add(name.text); + return; + } + for (const element of name.elements.nodes) { + if (!ts.isOmittedExpression(element) && element.name !== undefined) + addBindingNames(element.name, out); + } +} + +function isFunctionScope(n: ts.Node): n is ts.SignatureDeclaration { + return ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isConstructorDeclaration(n) + ); +} + +/** The `var` names a function body declares, without entering nested + * functions. Function declarations in nested blocks count as block-scoped + * here; treating one as free only adds an unused helper copy. */ +function varScopedNames(body: ts.Node): Set { + const out = new Set(); + ts.walkPreorder(body, (n) => { + if (n !== body && (isFunctionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n))) + return "skip"; + if (ts.isVariableDeclarationList(n) && (n.flags & ts.NodeFlags.BlockScoped) === 0) { + for (const d of n.declarations.nodes) addBindingNames(d.name, out); + } + return undefined; + }); + return out; +} + +/** The names one scope node binds for the references inside it. */ +function scopeNames(scope: ts.Node): Set { + const out = new Set(); + if (isFunctionScope(scope)) { + for (const param of scope.parameters.nodes) addBindingNames(param.name, out); + if (ts.isFunctionExpression(scope) && scope.name !== undefined) out.add(scope.name.text); + if (scope.body !== undefined) for (const name of varScopedNames(scope.body)) out.add(name); + } else if (ts.isBlock(scope) || ts.isSourceFile(scope) || ts.isCaseBlock(scope)) { + const statements = ts.isCaseBlock(scope) + ? scope.clauses.nodes.flatMap((clause) => clause.statements.nodes) + : scope.statements.nodes; + for (const stmt of statements) { + if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) && stmt.name) + out.add(stmt.name.text); + else if ( + ts.isVariableStatement(stmt) && + (stmt.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const d of stmt.declarationList.declarations.nodes) addBindingNames(d.name, out); + } + } + } else if (ts.isCatchClause(scope) && scope.variableDeclaration !== undefined) { + addBindingNames(scope.variableDeclaration.name, out); + } else if ( + (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) && + scope.initializer !== undefined && + ts.isVariableDeclarationList(scope.initializer) && + (scope.initializer.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const d of scope.initializer.declarations.nodes) addBindingNames(d.name, out); + } else if ( + (ts.isClassDeclaration(scope) || ts.isClassExpression(scope)) && + scope.name !== undefined + ) { + out.add(scope.name.text); + } + return out; +} + +/** Resolves references against one bundle's scopes, keeping each scope's + * names for the bundle's split. */ +class BundleScopes { + private readonly names = new Map>(); + + private of(scope: ts.Node): Set { + let names = this.names.get(scope); + if (names === undefined) { + names = scopeNames(scope); + this.names.set(scope, names); + } + return names; + } + + /** True when a scope between the reference and `root` (inclusive) + * binds its name. */ + boundWithin(id: ts.Identifier, root: ts.Node): boolean { + for (let n = id.parent; n !== undefined; n = n.parent) { + if (this.of(n).has(id.text)) return true; + if (n === root) return false; + } + return false; + } + + /** True when the reference resolves to a top-level binding of `sf`. */ + topLevel(id: ts.Identifier, sf: ts.SourceFile): boolean { + for (let n = id.parent; n !== undefined && n !== sf; n = n.parent) { + if (this.of(n).has(id.text)) return false; + } + return true; + } +} + /** Every binding reference inside a subtree. */ function references(root: ts.Node): ts.Identifier[] { const out: ts.Identifier[] = []; @@ -230,6 +343,33 @@ function copyableInitializer(init: ts.Expression | undefined): boolean { return depth > 0 && ts.isIdentifier(walk) && walk.text === "Object"; } +/** A top-level declaration whose value is a function. */ +function isFunctionValued(node: ts.VariableDeclaration | ts.FunctionDeclaration): boolean { + if (ts.isFunctionDeclaration(node)) return true; + const init = node.initializer && unparen(node.initializer); + return init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)); +} + +/** True when a reference is the function a call invokes: `f(...)`, or the + * `(0, f)(...)` form that drops the receiver. */ +function isCallee(id: ts.Identifier): boolean { + let callee: ts.Node = id; + const p = id.parent; + if ( + p !== undefined && + ts.isBinaryExpression(p) && + p.operatorToken.kind === ts.SyntaxKind.CommaToken && + p.right === id && + p.parent !== undefined && + ts.isParenthesizedExpression(p.parent) + ) + callee = p.parent; + while (callee.parent !== undefined && ts.isParenthesizedExpression(callee.parent)) + callee = callee.parent; + const call = callee.parent; + return call !== undefined && ts.isCallExpression(call) && call.expression === callee; +} + /** One recognized chunk declaration. */ interface Chunk { name: string; @@ -302,6 +442,12 @@ export function splitBundledChunks( if (declaredAnywhere.has("Object") || written.has("Object")) return { degrade: CHUNK_SHAPE_DEGRADE }; const stable = (name: string): boolean => !duplicate.has(name) && !written.has(name); + // Names whose every reference in the bundle is a callee: `f(...)` or the + // `(0, f)(...)` form. + const scopes = new BundleScopes(); + const valueUsed = new Set(); + for (const r of allRefs) if (!isCallee(r) && scopes.topLevel(r, sf)) valueUsed.add(r.text); + const calledOnly = (name: string): boolean => !valueUsed.has(name); const ownNames = (name: string): boolean => { const t = top.get(name); if (t === undefined || !stable(name) || !ts.isVariableDeclaration(t.node)) return false; @@ -422,7 +568,17 @@ export function splitBundledChunks( const out: BundledChunkSplit["chunks"] = []; const bundleEdits: Edit[] = [...callEdits]; for (const chunk of chunks.values()) { - const result = chunkSource(sf, chunk, chunks, top, stable, callEdits, bundleStrict); + const result = chunkSource( + sf, + chunk, + chunks, + top, + stable, + calledOnly, + scopes, + callEdits, + bundleStrict, + ); if (typeof result !== "string") return result; out.push({ path: chunk.path, source: result }); bundleEdits.push({ start: chunk.stmt.getStart(sf), end: chunk.stmt.getEnd(), text: "" }); @@ -479,6 +635,8 @@ function chunkSource( chunks: ReadonlyMap, top: ReadonlyMap, stable: (name: string) => boolean, + calledOnly: (name: string) => boolean, + scopes: BundleScopes, callEdits: readonly Edit[], bundleStrict: boolean, ): string | { degrade: string } { @@ -563,7 +721,8 @@ function chunkSource( } continue; } - if (local.has(name)) continue; + // The factory function itself binds the parameters and `var`s. + if (scopes.boundWithin(r, body.parent ?? body)) continue; if (name === "exports" || name === "module") return why(`reads the bundle's own '${name}' binding`); if (name === "__filename") return why("reads __filename, which names the bundle file"); @@ -587,9 +746,14 @@ function chunkSource( (ts.isVariableDeclaration(node) && copyableInitializer(node.initializer))); if (!pure || chunks.has(name)) return why(`reads the bundle's top-level binding '${name}', which it cannot carry along`); - const own = declaredNames(node); + // Each chunk gets its own copy, so a function's identity and its + // properties must stay unobservable: the bundle only ever calls it. + if (isFunctionValued(node) && !calledOnly(name)) + return why( + `uses the bundle's top-level function '${name}' as a value, which its chunks cannot share`, + ); for (const r of references(node)) { - if (own.has(r.text) || !top.has(r.text) || copied.has(r.text)) continue; + if (scopes.boundWithin(r, node) || !top.has(r.text) || copied.has(r.text)) continue; copied.add(r.text); copyQueue.push(r.text); } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 908a587ea..12d9b703a 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3207,11 +3207,30 @@ function cjsCycleRefusal7( function functionExportRead7(id: ts.Identifier, dep: ts.SourceFile): boolean { const access = id.parent; if (!ts.isPropertyAccessExpression(access) || access.expression !== id) return false; + // The value the read yields: through parentheses and the right operand + // of a comma, so `(0, binding.name)(...)` is the call it spells. let use: ts.Node = access; - while (ts.isParenthesizedExpression(use.parent)) use = use.parent; + for (;;) { + const p = use.parent; + if (ts.isParenthesizedExpression(p)) use = p; + else if ( + ts.isBinaryExpression(p) && + p.operatorToken.kind === ts.SyntaxKind.CommaToken && + p.right === use + ) + use = p; + else break; + } const parent = use.parent; + // A use that can run the function (a call, `new`, a tag, a member of it + // such as `.call`, or an argument another call receives) is not a read. if ( (ts.isCallExpression(parent) && parent.expression === use) || + (ts.isCallExpression(parent) && parent.arguments.nodes.includes(use as ts.Expression)) || + ts.isNewExpression(parent) || + (ts.isTaggedTemplateExpression(parent) && parent.tag === use) || + (ts.isPropertyAccessExpression(parent) && parent.expression === use) || + (ts.isElementAccessExpression(parent) && parent.expression === use) || ts.isDeleteExpression(parent) || ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && (parent.operator === ts.SyntaxKind.PlusPlusToken || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json new file mode 100644 index 000000000..90e304cbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json new file mode 100644 index 000000000..6443d170b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json new file mode 100644 index 000000000..f51a29951 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts b/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts new file mode 100644 index 000000000..d52b3731d --- /dev/null +++ b/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts @@ -0,0 +1,7 @@ +// A bundled CommonJS chunk that calls the bundle's top-level `label` while +// a nested function of the chunk declares its own `label`: the chunk +// carries the top-level function along, and each reference keeps its own +// binding. +import { inner, outer } from "gtchunkshadow"; + +console.log(outer(), inner()); diff --git a/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts b/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts new file mode 100644 index 000000000..21ca81b40 --- /dev/null +++ b/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts @@ -0,0 +1,7 @@ +// Two bundled CommonJS chunks export the bundle's top-level `tally` and +// count calls on a property of it. Node prints "1 2 true": both chunks +// share one function. A per-chunk copy would split its identity and its +// properties, so the package stays refused. +import { counts, same } from "gtchunkshared"; + +console.log(counts(), same()); diff --git a/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts b/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts new file mode 100644 index 000000000..3cca145e0 --- /dev/null +++ b/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts @@ -0,0 +1,7 @@ +// A require cycle whose partner calls an export of index.js with esbuild's +// `(0, import_index.peek)()` spelling while index is still initializing: +// peek reads a const in its temporal dead zone, so Node throws a +// ReferenceError, and scriptc refuses the cycle. +import { show } from "gtcyclecall"; + +console.log(show()); diff --git a/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts new file mode 100644 index 000000000..238cf1744 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare function outer(): string; +export declare function inner(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js new file mode 100644 index 000000000..0f89c7ec5 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js @@ -0,0 +1,52 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +function label(n) { + return "label " + n; +} + +// chunk/vendor/shadow.js +var require_shadow = __commonJS({ + "chunk/vendor/shadow.js"(exports2, module2) { + function nested() { + var label = "nested"; + return label; + } + module2.exports = { outer: label(1), inner: nested() }; + } +}); + +// chunk/src/index.ts +var index_exports = {}; +__export(index_exports, { + inner: () => inner, + outer: () => outer +}); +module.exports = __toCommonJS(index_exports); +function outer() { + return require_shadow().outer; +} +function inner() { + return require_shadow().inner; +} +0 && (module.exports = { + inner, + outer +}); diff --git a/tests/fixtures/npm/node_modules/gtchunkshadow/package.json b/tests/fixtures/npm/node_modules/gtchunkshadow/package.json new file mode 100644 index 000000000..6830fbd2c --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshadow/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtchunkshadow", + "version": "1.0.0", + "description": "esbuild's CommonJS bundle shape: a chunk calls a top-level function whose name a nested scope of the chunk also declares", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts new file mode 100644 index 000000000..3a47a0495 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare function same(): boolean; +export declare function counts(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js new file mode 100644 index 000000000..0d3aacec8 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js @@ -0,0 +1,56 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +function tally() { + tally.calls = (tally.calls || 0) + 1; + return tally.calls; +} + +// chunk/vendor/a.js +var require_a = __commonJS({ + "chunk/vendor/a.js"(exports2, module2) { + module2.exports = { tally, first: tally() }; + } +}); + +// chunk/vendor/b.js +var require_b = __commonJS({ + "chunk/vendor/b.js"(exports2, module2) { + module2.exports = { tally, second: tally() }; + } +}); + +// chunk/src/index.ts +var index_exports = {}; +__export(index_exports, { + counts: () => counts, + same: () => same +}); +module.exports = __toCommonJS(index_exports); +function same() { + return require_a().tally === require_b().tally; +} +function counts() { + return require_a().first + " " + require_b().second; +} +0 && (module.exports = { + counts, + same +}); diff --git a/tests/fixtures/npm/node_modules/gtchunkshared/package.json b/tests/fixtures/npm/node_modules/gtchunkshared/package.json new file mode 100644 index 000000000..bfc90c5d2 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshared/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtchunkshared", + "version": "1.0.0", + "description": "esbuild's CommonJS bundle shape: two chunks export one top-level function and count calls on a property of it", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts new file mode 100644 index 000000000..5f4373e2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts @@ -0,0 +1,3 @@ +export declare const LIMIT = 3; +export declare function show(): string; +export declare function peek(): number; diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js new file mode 100644 index 000000000..77aca3c9b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js @@ -0,0 +1,37 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + peek: () => peek, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function peek() { + return LIMIT; +} +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js new file mode 100644 index 000000000..c98d1ae46 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js @@ -0,0 +1,33 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const captured = (0, import_index.peek)(); +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/package.json b/tests/fixtures/npm/node_modules/gtcyclecall/package.json new file mode 100644 index 000000000..cc1069a6f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcyclecall", + "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output shape of a cycle whose partner calls an exported function through (0, binding.fn)() while that function's const binding is uninitialized", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 4b3b9fdee..451441009 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,12 +19,12 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469 and 2556-2562 are the --npm-static bundler-emitted-CJS + // 2465-2469 and 2556-2566 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-2])-[^/]+\/main\.ts$/.test(entry)) + .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-24-6])-[^/]+\/main\.ts$/.test(entry)) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index a8df98c52..4930487e2 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,7 +76,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2562) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2566) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ]; for (const f of inputs) hash.update(f).update(readFileSync(f)); @@ -1466,6 +1466,46 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 180_000); + // 2566: the same early read through esbuild's `(0, binding.fn)()` call + // spelling also keeps SC1016. + test("a CommonJS cycle that calls an export during initialization stays refused", () => { + const entry = join(fixturesRoot, "npm/cases/2566-cjs-cycle-indirect-call/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtcyclecall"] }); + expect(coverage.npmStatic).toEqual([ + { + package: "gtcyclecall", + status: "refused", + detail: expect.stringMatching( + /circular imports .*can use its binding 'import_index' of a module of the cycle/, + ) as string, + }, + ]); + }, 120_000); + + // 2564: a chunk reads a top-level function whose name a nested scope of + // the chunk also declares; the function still travels with the chunk. + test("a chunk carries a top-level function its nested scopes shadow", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2564-chunk-helper-shadowing/main.ts"), [ + "gtchunkshadow", + ]); + }, 180_000); + + // 2565: Node shares one function between the chunks that export it; a + // per-chunk copy would not, so the package stays refused. + test("chunks that export a shared top-level function stay refused", () => { + const entry = join(fixturesRoot, "npm/cases/2565-chunk-shared-function/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtchunkshared"] }); + expect(coverage.npmStatic).toEqual([ + { + package: "gtchunkshared", + status: "refused", + detail: expect.stringMatching( + /uses the bundle's top-level function 'tally' as a value, which its chunks cannot share/, + ) as string, + }, + ]); + }, 120_000); + test("published type-only declarations coexist with a static runtime bundle", async () => { const entry = join(fixturesRoot, "npm/cases/2469-bundle-offender/main.ts"); const { coverage } = analyze(entry, { npmStatic: ["gtghost"] }); From 2f7ac109266e0fc1e11da7c4e59f47d89961a9d8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 19:45:33 -0500 Subject: [PATCH 38/85] Treat bindings the loop initializer writes as variant in typed-array loop versions The bounds guard of a versioned typed-array loop runs before the loop's initializer, and the stability scan covered the body, condition and update only. An initializer that rebinds the indexed array (`for (let i = (a = b, 0); i < n; i++) a[i]`) kept the bounds the guard proved for the old array: the unchecked loop then read and wrote past a shorter replacement. The scan now includes the initializer, so a binding it writes is not invariant and the loop runs checked. Loops whose initializer leaves the receiver alone version as before. --- .../src/backend/llvm/bytes-loop-versions.ts | 4 ++ ...d-array-loop-initializer-rebinding.ts.json | 6 +++ ...-typed-array-loop-initializer-rebinding.ts | 46 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 58 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts.json create mode 100644 tests/corpus/5469-typed-array-loop-initializer-rebinding.ts diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index e890c3ee1..df8977426 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -127,7 +127,11 @@ export function matchBytesLoopVersion( } return everyStmtChild(s, scanExpr, scanStmt); }; + // The guard runs before the initializer, so a binding the initializer + // writes is not invariant either: `for (let i = (a = b, 0); ...)` must + // not keep the bounds the guard proved for the old `a`. if ( + (loop.init !== null && !scanStmt(loop.init)) || !loop.body.every(scanStmt) || (loop.cond !== null && !scanExpr(loop.cond)) || (loop.update !== null && !scanStmt(loop.update)) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts.json new file mode 100644 index 000000000..79c7d0c63 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts b/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts new file mode 100644 index 000000000..f899b9d07 --- /dev/null +++ b/tests/corpus/5469-typed-array-loop-initializer-rebinding.ts @@ -0,0 +1,46 @@ +// A typed-array loop whose initializer rebinds the array it indexes: the +// bounds the loop relies on belong to the array the initializer installs, +// so reads past the shorter replacement are undefined (NaN in arithmetic) +// and writes past it are ignored, as in JavaScript. + +function sumAfterRebinding(data: Uint8Array, replacement: Uint8Array, n: number): number { + let total = 0; + for (let i = ((data = replacement), 0); i < n; i++) total += data[i]; + return total; +} + +function fillAfterRebinding(data: Int32Array, replacement: Int32Array, n: number): Int32Array { + for (let i = ((data = replacement), 0); i < n; i++) data[i] = i + 1; + return data; +} + +// The initializer assigns the receiver without declaring the counter. +function countAfterAssignment(data: Float64Array, replacement: Float64Array, n: number): number { + let i: number; + let total = 0; + for (data = replacement, i = 0; i < n; i++) total += data[i]; + return total; +} + +const long = new Uint8Array(64).fill(1); +const short = new Uint8Array(4).fill(2); +console.log("sum", sumAfterRebinding(long, short, 64)); +console.log("sum in bounds", sumAfterRebinding(long, short, 4)); + +const target = new Int32Array(64); +const small = new Int32Array(3); +const filled = fillAfterRebinding(target, small, 64); +console.log("fill", filled.length, Array.from(filled).join(","), target[0], target[63]); + +const wide = new Float64Array(32).fill(0.5); +const narrow = new Float64Array(2).fill(4); +console.log("count", countAfterAssignment(wide, narrow, 32)); +console.log("count in bounds", countAfterAssignment(wide, narrow, 2)); + +// A loop whose initializer leaves the receiver alone still runs normally. +function plainSum(data: Uint8Array, n: number): number { + let total = 0; + for (let i = 0; i < n; i++) total += data[i]; + return total; +} +console.log("plain", plainSum(long, 64), plainSum(short, 6)); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index e4dd020cd..7de00df2b 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1886,6 +1886,7 @@ "5464-locale-compare-fractions.ts": 3156, "5465-dyn-index-read-arrays.ts": 3603, "5466-dyn-index-read-special.ts": 2823, + "5469-typed-array-loop-initializer-rebinding.ts": 1453, "5470-strip-vt-osc.cjs": 2872, "5471-strip-vt-csi.cjs": 2359, "5472-strip-vt-random.cjs": 3265, @@ -4899,6 +4900,7 @@ "5464-locale-compare-fractions.ts": 3409, "5465-dyn-index-read-arrays.ts": 3798, "5466-dyn-index-read-special.ts": 4384, + "5469-typed-array-loop-initializer-rebinding.ts": 2058, "5470-strip-vt-osc.cjs": 3494, "5471-strip-vt-csi.cjs": 3770, "5472-strip-vt-random.cjs": 3968, From 0b00ecfc237b5e905b63e5d5d3744ff773b44a89 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 21:44:33 -0500 Subject: [PATCH 39/85] Read x.constructor on EventEmitter and stream instances `x.constructor` on a value typed as EventEmitter or a stream class stopped at SC2020. The runtime now records each instance's Node class name: fs streams as ReadStream and WriteStream, child process stdio as Socket, worker stdio as ReadableWorkerStdio and WritableWorkerStdio, and Hash/Hmac stream views as Hash and Hmac (zlib streams, the base classes and program subclasses already did). The name is the one Node's leak warning and argument-type errors print, so those messages now name the same class as Node. `x.constructor.name` reads that name (scr_emitter_class_name). `x.constructor === C` for EventEmitter or a stream class tests the instance's own class: C's interval, none of its direct subclasses', and C's name, as fs, zlib, crypto, child process and worker streams share the base classes' vtables. `x.constructor` as a value is the class object of EventEmitter or the stream class the instance belongs to; for instances of the other runtime classes and of program subclasses it reports SC1090 at run time. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../lowering/builtins/runtime-constructor.ts | 181 ++++++++++++++++++ .../frontend/lowering/expressions/binary.ts | 22 +++ .../lowering/expressions/property-access.ts | 15 ++ .../compiler/src/ir/builtin-signatures.ts | 3 + .../corpus/5983-runtime-constructor.ts.json | 6 + packages/runtime/src/scr_child.c | 4 +- packages/runtime/src/scr_child.h | 8 +- packages/runtime/src/scr_crypto.h | 1 + packages/runtime/src/scr_events_emitter.c | 7 + packages/runtime/src/scr_events_emitter.h | 1 + packages/runtime/src/scr_fs_stream.c | 1 + packages/runtime/src/scr_lib.c | 2 + packages/runtime/src/scr_stream.c | 15 +- packages/runtime/src/scr_worker_events.c | 5 +- tests/corpus/5983-runtime-constructor.ts | 48 +++++ .../5983-runtime-constructor.ts.json | 1 + 17 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtins/runtime-constructor.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5983-runtime-constructor.ts.json create mode 100644 tests/corpus/5983-runtime-constructor.ts create mode 100644 tests/harness/corpus-costs/5983-runtime-constructor.ts.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 2d91386b3..d01374235 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -994,6 +994,7 @@ export const LIB_FN_SYMS: Record = { // family have non-generic shapes (adapter/variadic) — special-cased in // emitLibCall. "emitter.new": "scr_emitter_new", + "emitter.className": "scr_emitter_class_name", "emitter.ctor": "scr_emitter_init", "emitter.off": "scr_emitter_off", "emitter.offDyn": "scr_emitter_off_dyn", diff --git a/packages/compiler/src/frontend/lowering/builtins/runtime-constructor.ts b/packages/compiler/src/frontend/lowering/builtins/runtime-constructor.ts new file mode 100644 index 000000000..4b61f98c1 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/runtime-constructor.ts @@ -0,0 +1,181 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { type ClassInfo } from "../lower-classes.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { varRef } from "../../../ir/build.js"; +import { BOOL, STRING, type IrExpr, type IrType, type SrcLoc } from "../../../ir/ir.js"; + +/** `x.constructor` on an instance typed as EventEmitter or a stream class + * in a TypeScript file: the receiver's runtime ClassInfo, or null. The + * runtime records each instance's Node class name (EventEmitter, Readable, + * ReadStream, Gzip, Socket, a program subclass's name), which the + * lowerings below read with emitter.className. */ +export function runtimeConstructorReceiver( + lowerer: Lowerer, + expr: ts.Expression, +): { receiver: ts.Expression; info: ClassInfo } | null { + let e = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isPropertyAccessExpression(e) || e.questionDotToken) return null; + if (e.name.text !== "constructor" || isJsSourceFile(e.getSourceFile())) return null; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(e.expression)); + if (mapped?.kind !== "object") return null; + const info = lowerer.classes.get(mapped.className); + if (!info?.def.runtime || (!info.builtinEmitter && info.builtinStream === undefined)) return null; + return { receiver: e.expression, info }; +} + +/** `x.constructor.name`: the instance's class name. */ +export function lowerRuntimeConstructorName( + lowerer: Lowerer, + receiver: ts.Expression, + loc: SrcLoc, +): IrExpr { + return { + kind: "libCall", + fn: "emitter.className", + args: [lowerer.lowerExpr(receiver)], + type: STRING, + loc, + }; +} + +/** `x.constructor === C` for EventEmitter or a stream class C: true when + * the instance's own class is C, which needs both the vtable (C's own + * interval, not a subclass's) and the class name (fs, zlib, crypto, child + * process and worker streams share the base classes' vtables). */ +export function lowerRuntimeConstructorIs( + lowerer: Lowerer, + receiver: ts.Expression, + target: ClassInfo, + negated: boolean, + loc: SrcLoc, +): IrExpr { + const value = lowerer.lowerExpr(receiver); + const local = lowerer.declareHiddenLocal("%constructorOf", value.type); + const ref = varRef(local.id, value.type, loc); + const test = exactClassTest(lowerer, ref, target, loc); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result: negated ? { kind: "unary", op: "!", operand: test, type: BOOL, loc } : test, + type: BOOL, + loc, + }; +} + +/** `x.constructor` as a value: the class object of the instance's own class + * among EventEmitter and the stream classes, as the receiver's static + * class's class value. Instances of the other runtime classes (fs, zlib, + * crypto, child process and worker streams) and of program subclasses + * report SC1090 at run time. */ +export function lowerRuntimeConstructor( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, + info: ClassInfo, +): IrExpr { + const loc = locOf(access); + const resultT: IrType = { kind: "classval", className: info.def.name }; + const value = lowerer.lowerExpr(access.expression); + const local = lowerer.declareHiddenLocal("%constructorOf", value.type); + const ref = varRef(local.id, value.type, loc); + let result: IrExpr = refusalCall(lowerer, resultT, loc); + for (const candidate of valueClassesUnder(info)) { + lowerer.defineClassValueConstruct(candidate); + lowerer.defineClassValueConstruct(info); + const classRef: IrExpr = { + kind: "classRef", + className: candidate.def.name, + type: { kind: "classval", className: candidate.def.name }, + loc, + }; + result = { + kind: "ternary", + cond: exactClassTest(lowerer, ref, candidate, loc), + then: candidate === info ? classRef : { kind: "upcast", value: classRef, type: resultT, loc }, + else_: result, + type: resultT, + loc, + }; + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result, + type: resultT, + loc, + }; +} + +/** `info` and its runtime descendants whose values construct. */ +function valueClassesUnder(info: ClassInfo): ClassInfo[] { + const out: ClassInfo[] = []; + const visit = (c: ClassInfo): void => { + if (!c.def.runtime) return; + if (c.valueCtorParams !== undefined) out.push(c); + for (const s of c.subclasses) visit(s); + }; + visit(info); + return out; +} + +/** The instance is of class `target` itself: inside its interval, outside + * each direct subclass's (runtime or program), and named as it. */ +function exactClassTest(lowerer: Lowerer, ref: IrExpr, target: ClassInfo, loc: SrcLoc): IrExpr { + let test: IrExpr = { + kind: "instanceOf", + value: ref, + className: target.def.name, + type: BOOL, + loc, + }; + for (const sub of target.subclasses) { + test = { + kind: "logical", + op: "&&", + left: test, + right: { + kind: "unary", + op: "!", + operand: { kind: "instanceOf", value: ref, className: sub.def.name, type: BOOL, loc }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + const name: IrExpr = { + kind: "strEq", + negated: false, + left: { kind: "libCall", fn: "emitter.className", args: [ref], type: STRING, loc }, + right: { kind: "strLit", value: target.def.name.slice(1), type: STRING, loc }, + type: BOOL, + loc, + }; + return { kind: "logical", op: "&&", left: test, right: name, type: BOOL, loc }; +} + +/** The run-time refusal for an instance whose class has no class value. */ +function refusalCall(lowerer: Lowerer, type: IrType, loc: SrcLoc): IrExpr { + const name = `%constructor.refusal:${type.kind === "classval" ? type.className : ""}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: type, + body: [ + { + kind: "runtimeFence", + code: "SC1090", + message: + "reading 'constructor' of an fs, zlib, crypto, child process or worker stream, or of an instance of a program subclass of EventEmitter or a stream class, is not supported yet", + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [], type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 2a8de78b9..cecbde717 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -66,6 +66,10 @@ import { lowerAbstractEquality } from "../abstract-equality.js"; import { lowerInExpression } from "./relational.js"; import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js"; import { builtinClassRefOf, sameBuiltinClass } from "../builtins/class-values.js"; +import { + lowerRuntimeConstructorIs, + runtimeConstructorReceiver, +} from "../builtins/runtime-constructor.js"; import { tryLowerIndexedComparison } from "../indexed-comparison.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "../strict-equality.js"; import { withUnitArm } from "../../type-mapper.js"; @@ -1322,6 +1326,24 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr lowerUnionTypeofTest(lowerer, expr, loc); if (test) return test; } + // `x.constructor === Readable` for an instance of EventEmitter or a + // stream class: the instance's own class, compared without a class value. + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + for (const [side, other] of [ + [expr.left, expr.right], + [expr.right, expr.left], + ] as const) { + const ctor = runtimeConstructorReceiver(lowerer, side); + const target = ctor ? builtinClassRefOf(lowerer, other) : null; + if (ctor && (target?.kind === "emitter" || target?.kind === "stream")) { + const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; + return lowerRuntimeConstructorIs(lowerer, ctor.receiver, target.info, negated, loc); + } + } + } // `C === EventEmitter` between two builtin class references: the // classes are compile-time values, so the answer folds. if ( diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 7f0b2ddbb..1a5298379 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -76,6 +76,11 @@ import { lifetimeRegionMemberOf } from "../../experimental-import.js"; import { lowerInternTableProperty } from "../intern-tables.js"; import { lowerThreadSlotsProperty } from "../thread-slots.js"; import { builtinClassRefOf, runtimeClassValueOfType } from "../builtins/class-values.js"; +import { + lowerRuntimeConstructor, + lowerRuntimeConstructorName, + runtimeConstructorReceiver, +} from "../builtins/runtime-constructor.js"; import { findGenericMethodOn, lowerStaticFieldRead } from "../lower-classes.js"; import { lowerClassPrototypeData } from "../class-prototypes.js"; import { lowerEnumAccess } from "../lower-enums.js"; @@ -290,6 +295,16 @@ export function lowerPropertyAccessExpr( const ref = builtinClassRefOf(lowerer, expr.expression); if (ref !== null) return { kind: "strLit", value: ref.name, type: STRING, loc }; } + // `x.constructor.name` and `x.constructor` on an instance of + // EventEmitter or a stream class (builtins/runtime-constructor.ts). + if (expr.name.text === "name" && !expr.questionDotToken) { + const ctor = runtimeConstructorReceiver(lowerer, expr.expression); + if (ctor) return lowerRuntimeConstructorName(lowerer, ctor.receiver, loc); + } + { + const ctor = runtimeConstructorReceiver(lowerer, expr); + if (ctor) return lowerRuntimeConstructor(lowerer, expr, ctor.info); + } // `C.name` through a runtime-provided class VALUE (a `typeof Readable` // parameter): the class object's stored name. if ( diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 697a185a5..2a0e5124a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3499,6 +3499,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * throws err when unhandled. count/countFn/names/listeners/getMax/ * setMax/setDefaultMax/getDefaultMax are the introspection surface. */ "emitter.new": { argTypes: [], result: VOID }, + /** `x.constructor.name` of an emitter or stream (a borrowed object of + * EventEmitter's hierarchy): the +1 class name the instance records. */ + "emitter.className": { argTypes: [null], result: STRING }, "emitter.ctor": { argTypes: [null], result: VOID }, "emitter.on": { argTypes: [null, STRING, null, BOOL, BOOL], result: VOID }, "emitter.off": { argTypes: [null, STRING, null], result: VOID }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5983-runtime-constructor.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5983-runtime-constructor.ts.json new file mode 100644 index 000000000..004b1e350 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5983-runtime-constructor.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5983-runtime-constructor.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 87ead5755..c714fb555 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -6458,7 +6458,7 @@ static ScrDyn *scr_child_bridge_readable(ScrChildStream *reader, ScrDyn **cached return NULL; } if (!*cached) { - ScrStream *stream = scr_child_stream_ops->readable(); + ScrStream *stream = scr_child_stream_ops->readable("Socket"); scr_child_stream_on_data(reader, scr_child_bridge_callback(stream, (void *)scr_child_bridge_data), scr_child_bridge_data, false); scr_child_stream_on_end(reader, scr_child_bridge_callback(stream, (void *)scr_child_bridge_end), false); @@ -6578,7 +6578,7 @@ static ScrDyn *scr_child_bridge_writable(ScrChildWriter *writer, ScrDyn **cached return NULL; } if (!*cached) { - ScrStream *stream = scr_child_stream_ops->writable( + ScrStream *stream = scr_child_stream_ops->writable("Socket", scr_child_writer_bridge_callback(writer, (void *)scr_child_bridge_write), scr_child_bridge_write, scr_child_writer_bridge_callback(writer, (void *)scr_child_bridge_final), scr_child_bridge_final); *cached = scr_child_writable_box(stream); diff --git a/packages/runtime/src/scr_child.h b/packages/runtime/src/scr_child.h index 1187985c8..bbb50e0cb 100644 --- a/packages/runtime/src/scr_child.h +++ b/packages/runtime/src/scr_child.h @@ -253,9 +253,13 @@ void scr_child_dyn_install(void); void scr_child_on_dyn(ScrChild *child, ScrStr *event, ScrDyn *listener); /* borrowed */ /* Optional native-stream bridge. The table avoids a stream dependency * in programs that only use the child-process surface. */ +/* readable/writable take the stream's class name (the constructor name + * Node reports: Socket for child stdio, ReadableWorkerStdio and + * WritableWorkerStdio for worker stdio). */ typedef struct ScrChildStreamOps { - ScrStream *(*readable)(void); - ScrStream *(*writable)(ScrClosure *, ScrStreamChunkInv, ScrClosure *, ScrStreamPlainInv); + ScrStream *(*readable)(const char *cls); + ScrStream *(*writable)(const char *cls, ScrClosure *, ScrStreamChunkInv, ScrClosure *, + ScrStreamPlainInv); void *(*retain)(void *); void (*release)(void *); ScrTraceFn trace; diff --git a/packages/runtime/src/scr_crypto.h b/packages/runtime/src/scr_crypto.h index 4b6b6a6a6..d71f46b13 100644 --- a/packages/runtime/src/scr_crypto.h +++ b/packages/runtime/src/scr_crypto.h @@ -71,6 +71,7 @@ void scr_crypto_hash_stream_set(ScrCryptoHash *hash, void *stream, void (*releas void scr_crypto_hash_stream_detach(void *hash); void scr_crypto_hash_stream_close(ScrCryptoHash *hash); bool scr_crypto_hash_stream_usable(const ScrCryptoHash *hash); +bool scr_crypto_hash_is_hmac(const ScrCryptoHash *hash); void scr_crypto_hash_stream_hold(ScrCryptoHash *hash); void scr_crypto_hash_stream_unhold(ScrCryptoHash *hash); ScrStream *scr_crypto_hash_stream_piped(ScrCryptoHash *hash); diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index ceecb15e3..e0e305bde 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -324,6 +324,13 @@ ScrEmitter *scr_emitter_new(void) { return em; } +/* x.constructor.name of an emitter, a stream, or an instance of an emitted + * subclass: the class name every allocation stamps (EventEmitter, + * Readable, ReadStream, Gzip, Socket, the program class's name). +1. */ +ScrStr *scr_emitter_class_name(const ScrEmitter *em) { + return scr_str_new(em->cls, strlen(em->cls)); +} + /* super() into the emitter prefix of an emitted subclass: the allocation * already zeroed reg and stamped the concrete class's display name, so * construction has nothing left to do — the call exists so the super() diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index 99da57a50..a92ae1864 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -38,6 +38,7 @@ extern SCR_TL ScrVt scr_emitter_vt; /* main() stamps pre/post */ extern SCR_TL double scr_emitter_default_max; ScrEmitter *scr_emitter_new(void); /* +1, collector-headered */ +ScrStr *scr_emitter_class_name(const ScrEmitter *em); /* +1 */ /* Internal owner hook; observes listener presence without running script. */ void scr_emitter_observe(ScrEmitter *em, void (*observe)(void *, const ScrStr *, bool), void *context); diff --git a/packages/runtime/src/scr_fs_stream.c b/packages/runtime/src/scr_fs_stream.c index 97bf4ebaa..70a5ab075 100644 --- a/packages/runtime/src/scr_fs_stream.c +++ b/packages/runtime/src/scr_fs_stream.c @@ -562,6 +562,7 @@ static ScrStream *scr_fss_new(const ScrDyn *path, const ScrDyn *opts_in, bool re scr_dyn_release(eo); } if (!s) goto fail; + s->cls = readable ? "ReadStream" : "WriteStream"; bool unknown_encoding = false; if (!readable) { /* setDefaultEncoding(options.encoding) after the Writable constructor: diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 930d44818..52036e02c 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -8473,6 +8473,8 @@ void scr_crypto_hash_stream_close(ScrCryptoHash *hash) { if (stream) stream_release(stream); } +bool scr_crypto_hash_is_hmac(const ScrCryptoHash *hash) { return hash->hmac; } + bool scr_crypto_hash_stream_usable(const ScrCryptoHash *hash) { if (!hash->stream_closed) return true; static const char msg[] = "Stream use of a Hash or Hmac after its stream closed has no lowering"; diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 244e2ec5d..c2d27e7ce 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6350,15 +6350,19 @@ void scr_stream_install(void) { static void scr_stream_child_read(ScrClosure *cb, ScrStream *s, double size) { (void)cb; (void)s; (void)size; } -static ScrStream *scr_stream_child_readable(void) { - return scr_stream_new_readable(-1, true, true, +static ScrStream *scr_stream_child_readable(const char *cls) { + ScrStream *s = scr_stream_new_readable(-1, true, true, scr_closure_new((void *)scr_stream_child_read, 0), scr_stream_child_read, NULL, NULL); + s->cls = cls; + return s; } -static ScrStream *scr_stream_child_writable(ScrClosure *write, ScrStreamChunkInv write_inv, - ScrClosure *final_cb, ScrStreamPlainInv final_inv) { - return scr_stream_new_writable(-1, true, true, write, write_inv, +static ScrStream *scr_stream_child_writable(const char *cls, ScrClosure *write, + ScrStreamChunkInv write_inv, ScrClosure *final_cb, ScrStreamPlainInv final_inv) { + ScrStream *s = scr_stream_new_writable(-1, true, true, write, write_inv, final_cb, final_inv, NULL, NULL); + s->cls = cls; + return s; } const ScrChildStreamOps scr_stream_child_ops = { scr_stream_child_readable, scr_stream_child_writable, @@ -6578,6 +6582,7 @@ ScrStream *scr_crypto_hash_stream(ScrCryptoHash *hash) { scr_hash_stream_step(box), scr_hash_stream_transform, scr_hash_stream_step(box), scr_hash_stream_flush, scr_hash_stream_step(box), scr_hash_stream_destroy); + s->cls = scr_crypto_hash_is_hmac(hash) ? "Hmac" : "Hash"; /* The steps keep the box alive as long as the stream. */ scr_crypto_hash_stream_set(hash, scr_stream_retain(s), scr_stream_release_v, box); scr_box_release(box); diff --git a/packages/runtime/src/scr_worker_events.c b/packages/runtime/src/scr_worker_events.c index 53041f3d5..b4efa3e0d 100644 --- a/packages/runtime/src/scr_worker_events.c +++ b/packages/runtime/src/scr_worker_events.c @@ -122,7 +122,7 @@ static ScrClosure *scr_worker_stdin_callback(ScrWorkerThread *thread, void *fire } static ScrStream *scr_worker_stdin_new(ScrWorkerThread *thread) { - return scr_worker_stdio_ops->writable( + return scr_worker_stdio_ops->writable("WritableWorkerStdio", scr_worker_stdin_callback(thread, (void *)scr_worker_stdin_write), scr_worker_stdin_write, scr_worker_stdin_callback(thread, (void *)scr_worker_stdin_final), scr_worker_stdin_final); } @@ -1350,7 +1350,8 @@ static ScrDyn *scr_worker_new_options(double root, ScrStr *filename, ScrDyn *opt for (int i = 0; i < 2; i++) { view->stdio_captured[i] = (capture >> i) & 1; /* without the stream unit nothing can read the stream: the output is dropped */ - if (view->stdio_captured[i] && scr_worker_stdio_ops) view->stdio[i] = scr_worker_stdio_ops->readable(); + if (view->stdio_captured[i] && scr_worker_stdio_ops) + view->stdio[i] = scr_worker_stdio_ops->readable("ReadableWorkerStdio"); } view->stdin_captured = capture & 4; if (view->stdin_captured && scr_worker_stdio_ops) view->stdin_stream = scr_worker_stdin_new(thread); diff --git a/tests/corpus/5983-runtime-constructor.ts b/tests/corpus/5983-runtime-constructor.ts new file mode 100644 index 000000000..3fb9672d0 --- /dev/null +++ b/tests/corpus/5983-runtime-constructor.ts @@ -0,0 +1,48 @@ +// x.constructor on instances of EventEmitter and the stream classes: +// .name is the class Node reports (fs, zlib and program subclasses +// included), === compares with the instance's own class, and the value +// is the class object for EventEmitter and the stream classes. +import { EventEmitter } from "node:events"; +import { Duplex, PassThrough, Readable, Transform, Writable } from "node:stream"; +import * as fs from "node:fs"; +import * as zlib from "node:zlib"; + +class Bus extends EventEmitter {} + +const emitter = new EventEmitter(); +const bus: EventEmitter = new Bus(); +console.log("emitter", emitter.constructor === EventEmitter, emitter.constructor.name); +console.log("subclass", bus.constructor === EventEmitter, bus.constructor.name); + +const streams: Readable[] = [new Readable(), new Duplex(), new Transform(), new PassThrough()]; +for (const s of streams) { + console.log( + "stream", + s.constructor.name, + s.constructor === Readable, + s.constructor === Duplex, + s.constructor === Transform, + s.constructor === PassThrough, + s.constructor !== Readable, + ); +} + +const from = Readable.from(["a"]); +console.log("from", from.constructor.name, from.constructor === Readable); + +const readStream: Readable = fs.createReadStream(import.meta.filename); +const writeStream: Writable = fs.createWriteStream("/dev/null"); +console.log("fs", readStream.constructor.name, readStream.constructor === Readable); +console.log("fs", writeStream.constructor.name, writeStream.constructor === Writable); +readStream.destroy(); +writeStream.destroy(); + +const gzip: Transform = zlib.createGzip(); +const inflate: Transform = zlib.createInflateRaw(); +console.log("zlib", gzip.constructor.name, inflate.constructor.name, gzip.constructor === Transform); + +const kinds: EventEmitter[] = [new EventEmitter(), new Writable(), new PassThrough()]; +for (const k of kinds) { + const Kind = k.constructor as typeof EventEmitter; + console.log("value", Kind.name, Kind === EventEmitter, k instanceof Kind, new Kind() instanceof EventEmitter); +} diff --git a/tests/harness/corpus-costs/5983-runtime-constructor.ts.json b/tests/harness/corpus-costs/5983-runtime-constructor.ts.json new file mode 100644 index 000000000..5506cd9a8 --- /dev/null +++ b/tests/harness/corpus-costs/5983-runtime-constructor.ts.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 6900 } From 297ec38f07a9ed09ccbc09a4664e02e6802756cb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 19:42:15 -0700 Subject: [PATCH 40/85] Promisify functions whose callback is optional `util.promisify(client.close.bind(client))` over `close(callback?: (error?: Error) => void)` was refused because the callback parameter is `callback | undefined`. The promisified function now takes the union's function arm and passes the callback into the union. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/promisify-callback.ts | 39 ++++++++++++++----- .../6428-promisify-callback-functions.ts | 13 +++++-- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts index cbb13d7f8..693079d3f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts @@ -25,6 +25,9 @@ export interface CallbackShape { resultT: IrType & { kind: "func" }; args: readonly IrType[]; callbackT: IrType & { kind: "func" }; + /** The parameter's declared type: callbackT, or `callbackT | undefined` + * for an optional callback. */ + callbackParamT: IrType; inner: IrType; /** The error parameter's union and the tag of its Error arm, or null when * the callback declares no error parameter. */ @@ -39,7 +42,20 @@ function callbackShape( ): CallbackShape | null { if (fnT?.kind !== "func" || resultT?.kind !== "func") return null; if (fnT.rest || fnT.restAbi || fnT.argumentsAll || resultT.rest || resultT.restAbi) return null; - const callbackT = fnT.params[fnT.params.length - 1]; + const callbackParamT = fnT.params[fnT.params.length - 1]; + // An optional callback (`close(callback?: (err?: Error) => void)`) takes + // its function arm; the call passes the callback into the union. + const callbackT = + callbackParamT?.kind === "union" + ? (() => { + const arms = lowerer.unions.get(callbackParamT.unionId)?.arms ?? []; + const fns = arms.filter((arm) => arm.kind === "func"); + return fns.length === 1 && + arms.every((arm) => arm.kind === "func" || arm.kind === "undefinedT") + ? fns[0] + : undefined; + })() + : callbackParamT; if (callbackT?.kind !== "func" || callbackT.ret.kind !== "void") return null; if (callbackT.rest || callbackT.restAbi || callbackT.params.length > 2) return null; const args = fnT.params.slice(0, -1); @@ -71,7 +87,7 @@ function callbackShape( const probe: IrExpr = { kind: "varRef", localId: "%probe", type: valueT, loc }; if (!typeEquals(lowerer.coerceToExpected(probe, inner).type, inner)) return null; } - return { fnT, resultT, args, callbackT, inner, error }; + return { fnT, resultT, args, callbackT, callbackParamT: callbackParamT!, inner, error }; } /** The callback shape of a `promisify(fn)` call, or null. */ @@ -137,7 +153,7 @@ function promisifyMaker(lowerer: Lowerer, shape: CallbackShape, loc: SrcLoc): st if (existing) return existing; const base = `%promisify.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, base); - const { fnT, resultT, args, callbackT, inner, error } = shape; + const { fnT, resultT, args, callbackT, callbackParamT, inner, error } = shape; const promiseT = resultT.ret; const resolveT = funcOf(inner.kind === "void" ? [] : [inner], VOID); const rejectT = funcOf([ERROR_T], VOID); @@ -270,13 +286,16 @@ function promisifyMaker(lowerer: Lowerer, shape: CallbackShape, loc: SrcLoc): st callee: varRef("f.0", fnT, loc), args: [ ...args.map((type, i) => varRef(argIds[i]!, type, loc)), - { - kind: "call", - callee: `${base}.mkcb`, - args: [varRef("res.0", resolveT, loc), varRef("rej.0", rejectT, loc)], - type: callbackT, - loc, - }, + lowerer.coerceToExpected( + { + kind: "call", + callee: `${base}.mkcb`, + args: [varRef("res.0", resolveT, loc), varRef("rej.0", rejectT, loc)], + type: callbackT, + loc, + }, + callbackParamT, + ), ], type: VOID, loc, diff --git a/tests/corpus/6428-promisify-callback-functions.ts b/tests/corpus/6428-promisify-callback-functions.ts index 9c2193882..45cf17052 100644 --- a/tests/corpus/6428-promisify-callback-functions.ts +++ b/tests/corpus/6428-promisify-callback-functions.ts @@ -1,6 +1,7 @@ -// util.promisify over typed callback-only functions (a bound method, a -// function declaration, an inline arrow): the error argument rejects, the -// value fulfills, a synchronous throw rejects, and settlement is async. +// util.promisify over typed callback-only functions (bound methods, one with +// an optional callback, a function declaration, an inline arrow): the error +// argument rejects, the value fulfills, a synchronous throw rejects, and +// settlement is async. import { promisify } from "node:util"; class Connection { @@ -19,6 +20,9 @@ class Connection { callback(); }, 1); } + flush(callback?: (error?: Error) => void): void { + setImmediate(() => callback?.(this.closed ? new Error("flushed after close") : undefined)); + } stats(callback: (error: Error | null, open: number) => void): void { setImmediate(() => (this.closed ? callback(new RangeError("closed"), 0) : callback(null, 3))); } @@ -32,6 +36,8 @@ async function main(): Promise { const c = new Connection("db"); const stats = promisify(c.stats.bind(c)); console.log("open", await stats()); + await promisify(c.flush.bind(c))(); + console.log("flushed"); const closeAsync = promisify(c.close.bind(c)); await closeAsync(); console.log("closed once"); @@ -45,6 +51,7 @@ async function main(): Promise { } catch (e) { console.log("stats:", (e as Error).name, (e as Error).message); } + await promisify(c.flush.bind(c))().catch((e) => console.log("flush:", (e as Error).message)); try { await promisify(boom)(); } catch (e) { From f176de2124189631f6c7d56a42d6849c45e853c5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 17:38:36 -0700 Subject: [PATCH 41/85] Construct CommonJS constructor functions imported through a member alias A package that exports a constructor function whole and also as a named member (`exports = module.exports = Client; exports.StatsD = Client`, the statsd-client shape) could not be constructed from TypeScript: `new StatsD(...)` was refused as "constructing values other than classes declared in the program", and a bare reference had "no lowering". - `exports = module.exports = value` is now the module's whole export, and it rebinds `exports` too, so members attached through `exports` afterwards are exported. The fresh-table reset `exports = module.exports = {}` keeps its own handling. - An `import { name }` or a top-level `const { name } = require(...)` of a member written once as `exports. = ` on such a module reads that value, following `module.exports = require("./x")` re-exports. The checker gives these bindings a synthesized property symbol, so the module's statements answer. - Construction then takes the existing checked path for JavaScript functions: prototype methods, `util.inherits` between two constructor functions, and `Parent.call(this, ...)` behave as in Node. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/cjs-exports-object.ts | 93 +++++++++++++++++++ .../lowering/expressions/identifiers.ts | 6 ++ .../src/frontend/lowering/lower-stmts.ts | 3 +- packages/compiler/src/frontend/program.ts | 29 +++++- .../main.ts.json | 6 ++ .../6431-cjs-exports-rebinding/main.cjs.json | 7 ++ .../main.ts | 19 ++++ .../node_modules/metrics-client/index.d.ts | 14 +++ .../node_modules/metrics-client/index.js | 1 + .../node_modules/metrics-client/lib/client.js | 50 ++++++++++ .../node_modules/metrics-client/package.json | 1 + .../corpus/6431-cjs-exports-rebinding/lib.cjs | 10 ++ .../6431-cjs-exports-rebinding/main.cjs | 6 ++ 13 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6430-npm-constructor-function-class/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json create mode 100644 tests/corpus/6430-npm-constructor-function-class/main.ts create mode 100644 tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.d.ts create mode 100644 tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.js create mode 100644 tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/lib/client.js create mode 100644 tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/package.json create mode 100644 tests/corpus/6431-cjs-exports-rebinding/lib.cjs create mode 100644 tests/corpus/6431-cjs-exports-rebinding/main.cjs diff --git a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts index 5ea4001fc..5ff34a326 100644 --- a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts +++ b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts @@ -22,12 +22,16 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { cjsExportAssignmentOf, + cjsExportDiscardReason, cjsExportTargetLiteral, isCjsJsFile, isModuleExportsAccess, isRequireStatement, locOf, + npmStaticDepSf7, + resolveImport, } from "../program.js"; +import { cjsWholeExportValueOf, inlineProgramRequireOf } from "./cjs-inline-require.js"; import { isCjsModuleGlobal } from "./lower-node-module.js"; import { DYN, type IrExpr, type IrGlobal } from "../../ir/ir.js"; @@ -428,3 +432,92 @@ export function cjsExportCallIgnoresThis( return false; }); } + +/** The value behind a binding of a CommonJS member export (an `import { + * name }` or a top-level `const { name } = require(...)`) written once as + * `module.exports. = ` or `exports. = `, when the + * module exports a value whole and the member is a property added to it + * (`exports = module.exports = Client; exports.StatsD = Client`). The + * checker gives such a binding a synthesized property symbol, so the + * module's statements answer. The value is a binding of the exporting + * module or a primitive literal, which the use site reads again. Follows + * `module.exports = require("./x")` re-exports. Null for any other binding. */ +export function cjsMemberAliasValueOf(lowerer: Lowerer, ref: ts.Identifier): ts.Expression | null { + const sym = lowerer.checker.getSymbolAtLocation(ref); + if (!sym) return null; + const decls = sym.declarations; + // `import { StatsD } from "pkg"` + const specifier = sym.flags & ts.SymbolFlags.Alias ? decls.find(ts.isImportSpecifier) : undefined; + if (specifier !== undefined) { + const importDecl = specifier.parent?.parent?.parent; + if ( + importDecl === undefined || + !ts.isImportDeclaration(importDecl) || + !ts.isStringLiteral(importDecl.moduleSpecifier) + ) + return null; + const spec = importDecl.moduleSpecifier.text; + const from = importDecl.getSourceFile(); + const dep = + resolveImport(lowerer.program, from, spec, "import") ?? + npmStaticDepSf7(lowerer.program, from, spec, "import"); + if (dep === null) return null; + return memberAliasIn(lowerer, dep, (specifier.propertyName ?? specifier.name).text, 0); + } + // `const { shout } = require("./lib")` at a module's top level + const element = decls.find(ts.isBindingElement); + if (element === undefined || element.dotDotDotToken || !ts.isIdentifier(element.name)) + return null; + const pattern = element.parent; + const declaration = pattern?.parent; + if ( + pattern === undefined || + !ts.isObjectBindingPattern(pattern) || + declaration === undefined || + !ts.isVariableDeclaration(declaration) || + declaration.initializer === undefined || + (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 + ) + return null; + const key = element.propertyName ?? element.name; + if (!ts.isIdentifier(key)) return null; + const req = inlineProgramRequireOf(lowerer, declaration.initializer); + if (req === null) return null; + return memberAliasIn(lowerer, req.dep, key.text, 0); +} + +function memberAliasIn( + lowerer: Lowerer, + sf: ts.SourceFile, + name: string, + depth: number, +): ts.Expression | null { + if (depth > 32 || sf.isDeclarationFile || !isCjsJsFile(sf, lowerer.program)) return null; + const whole = cjsWholeExportValueOf(sf); + if (whole === null) return null; + const inner = inlineProgramRequireOf(lowerer, whole.rhs); + if (inner !== null) return memberAliasIn(lowerer, inner.dep, name, depth + 1); + let found: ts.Expression | null = null; + let writes = 0; + for (const stmt of sf.statements.nodes) { + const cjs = cjsExportAssignmentOf(stmt); + if (cjs?.kind !== "member" || !ts.isIdentifier(cjs.name) || cjs.name.text !== name) continue; + writes++; + if (cjsExportDiscardReason(stmt) !== null) return null; + let value: ts.Expression = cjs.value; + while (ts.isParenthesizedExpression(value)) value = value.expression; + // A binding of the module, or a primitive literal: reading either + // again at the use site observes the member's value. + if ( + ts.isIdentifier(value) || + ts.isStringLiteral(value) || + ts.isNumericLiteral(value) || + ts.isNoSubstitutionTemplateLiteral(value) || + value.kind === ts.SyntaxKind.TrueKeyword || + value.kind === ts.SyntaxKind.FalseKeyword + ) + found = value; + } + // One write: a member written again would be a runtime slot. + return writes === 1 ? found : null; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 5a8169300..2ee56573c 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -1,4 +1,5 @@ /* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */ +import { cjsMemberAliasValueOf } from "../cjs-exports-object.js"; import * as ts from "../../ts7/adapter.js"; import * as posix from "node:path/posix"; import { type Lowerer, PoisonError } from "../lowerer.js"; @@ -859,6 +860,11 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: lowerer.checker.getSymbolAtLocation(expr), ); } + // An import of a CommonJS member export whose value is a binding of the + // exporting module (`module.exports = Client; module.exports.StatsD = + // Client`): the member holds that binding's value. + const memberAlias = cjsMemberAliasValueOf(lowerer, expr); + if (memberAlias !== null) return lowerer.lowerExpr(memberAlias); // Preflight guarantees no unresolved identifiers; anything else here // is a blocked declaration's binding (the SC2004 cascade) or a // binding form we don't model yet. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 41c048ae9..5fddb31d5 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -6916,7 +6916,8 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm if ( ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken && - isCjsExportsIdentifier(lowerer, expr.left) + isCjsExportsIdentifier(lowerer, expr.left) && + cjsExportAssignmentOf(stmtNode)?.kind !== "table" ) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index d7c2ba66a..906d901fe 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5967,10 +5967,15 @@ export function isModuleExportsAccess(expr: ts.Expression): expr is ts.PropertyA * - member: `exports.f = ` / `module.exports.f = `. * Null for everything else. Callers gate on top-level position in a JS * module file — these forms anywhere else keep their generic fences. */ -export function cjsExportAssignmentOf( - stmt: ts.Statement, -): - | { kind: "table"; obj: ts.ObjectLiteralExpression | null; expr: ts.BinaryExpression } +export function cjsExportAssignmentOf(stmt: ts.Statement): + | { + kind: "table"; + obj: ts.ObjectLiteralExpression | null; + expr: ts.BinaryExpression; + /** `exports = module.exports = value`: the `exports` binding names + * the new value too, so later `exports.f =` members attach to it. */ + rebindsExports?: true; + } | { kind: "member"; name: ts.MemberName; value: ts.Expression; expr: ts.BinaryExpression } | null { if (!ts.isExpressionStatement(stmt)) return null; @@ -5979,6 +5984,20 @@ export function cjsExportAssignmentOf( if (isModuleExportsAccess(e.left)) { return { kind: "table", obj: ts.isObjectLiteralExpression(e.right) ? e.right : null, expr: e }; } + if (ts.isIdentifier(e.left) && e.left.text === "exports") { + let inner: ts.Expression = e.right; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + if ( + ts.isBinaryExpression(inner) && + inner.operatorToken.kind === ts.SyntaxKind.EqualsToken && + isModuleExportsAccess(inner.left) && + // `exports = module.exports = {}` is the fresh-table reset, which the + // export walk handles on its own. + !ts.isObjectLiteralExpression(inner.right) + ) { + return { kind: "table", obj: null, expr: inner, rebindsExports: true }; + } + } if (ts.isPropertyAccessExpression(e.left) && !e.left.questionDotToken) { const recv = e.left.expression; const isExports = ts.isIdentifier(recv) && recv.text === "exports"; @@ -6110,6 +6129,8 @@ export function cjsExportDiscardReason(stmt: ts.Statement): string | null { return "export members attached before a 'module.exports =' assignment (Node replaces the export object — importers never see this member; attach it to module.exports AFTER the table)"; } const left = mine.expr.left as ts.PropertyAccessExpression; + const last = cjsExportAssignmentOf(stmts[lastTable]!); + if (last?.kind === "table" && last.rebindsExports === true) return null; return ts.isIdentifier(left.expression) && left.expression.text === "exports" ? "'exports.f =' after a 'module.exports =' assignment ('exports' still references the replaced object — Node discards this member; write module.exports.f instead)" : null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6430-npm-constructor-function-class/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6430-npm-constructor-function-class/main.ts.json new file mode 100644 index 000000000..76bd8e813 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6430-npm-constructor-function-class/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6430-npm-constructor-function-class/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json new file mode 100644 index 000000000..25277f4a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6431-cjs-exports-rebinding/lib.cjs", + "/tests/corpus/6431-cjs-exports-rebinding/main.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/6430-npm-constructor-function-class/main.ts b/tests/corpus/6430-npm-constructor-function-class/main.ts new file mode 100644 index 000000000..6098c1e19 --- /dev/null +++ b/tests/corpus/6430-npm-constructor-function-class/main.ts @@ -0,0 +1,19 @@ +// Constructing a CommonJS constructor-function "class" imported by a named +// member alias (`exports = module.exports = Client; exports.StatsD = +// Client`) through a re-exporting package index: prototype methods, a child +// client linked with util.inherits, and a callback-style close. +import { StatsD } from "metrics-client"; + +const client = new StatsD({ prefix: "app." }); +client.onSent((line: string) => console.log("event", line)); +console.log(client.increment("boot")); +console.log(client.gauge("heap", 42)); +console.log(typeof client.increment, typeof client.onSent); +const child = client.childClient({ prefix: "db." }); +console.log(child.increment("query", 3)); +console.log(child.gauge("pool", 1), child.sent.length, client.sent.length); +const bare = new StatsD(); +console.log(bare.increment("x"), bare.sent.length); +client.close((error: Error | null, count: number) => { + console.log("closed", error, count); +}); diff --git a/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.d.ts b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.d.ts new file mode 100644 index 000000000..c53bdd8ae --- /dev/null +++ b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.d.ts @@ -0,0 +1,14 @@ +export interface ClientOptions { + prefix?: string; + suffix?: string; +} +export declare class StatsD { + constructor(options?: ClientOptions); + prefix: string; + sent: string[]; + onSent(listener: (line: string) => void): this; + increment(stat: string, value?: number): string; + gauge(stat: string, value: number): string; + close(callback: (error: Error | null, count: number) => void): void; + childClient(options?: ClientOptions): StatsD; +} diff --git a/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.js b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.js new file mode 100644 index 000000000..eb7384718 --- /dev/null +++ b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/index.js @@ -0,0 +1 @@ +module.exports = require("./lib/client"); diff --git a/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/lib/client.js b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/lib/client.js new file mode 100644 index 000000000..7dd5968c9 --- /dev/null +++ b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/lib/client.js @@ -0,0 +1,50 @@ +"use strict"; +// A constructor-function "class" in the style of statsd clients: a function +// expression, prototype methods, a child client linked with util.inherits, +// and the whole export plus a named alias. +const util = require("util"); + +const Client = function (options) { + const opts = options || {}; + this.prefix = opts.prefix || ""; + this.suffix = opts.suffix || ""; + this.sent = []; + this.listeners = []; +}; + +Client.prototype.send = function (stat, value, type) { + const line = this.prefix + stat + this.suffix + ":" + value + "|" + type; + this.sent.push(line); + for (const listener of this.listeners) listener(line); + return line; +}; +Client.prototype.onSent = function (listener) { + this.listeners.push(listener); + return this; +}; +Client.prototype.increment = function (stat, value) { + return this.send(stat, value === undefined ? 1 : value, "c"); +}; +Client.prototype.gauge = function (stat, value) { + return this.send(stat, value, "g"); +}; +Client.prototype.close = function (callback) { + const count = this.sent.length; + setImmediate(() => callback(null, count)); +}; +Client.prototype.childClient = function (options) { + return new ChildClient(this, options); +}; + +const ChildClient = function (parent, options) { + Client.call(this, { + prefix: parent.prefix + ((options && options.prefix) || ""), + suffix: parent.suffix, + }); + this.parent = parent; +}; +util.inherits(ChildClient, Client); + +exports = module.exports = Client; +exports.StatsD = Client; +exports.ChildClient = ChildClient; diff --git a/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/package.json b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/package.json new file mode 100644 index 000000000..af4ede9ee --- /dev/null +++ b/tests/corpus/6430-npm-constructor-function-class/node_modules/metrics-client/package.json @@ -0,0 +1 @@ +{ "name": "metrics-client", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6431-cjs-exports-rebinding/lib.cjs b/tests/corpus/6431-cjs-exports-rebinding/lib.cjs new file mode 100644 index 000000000..52b6b8c9b --- /dev/null +++ b/tests/corpus/6431-cjs-exports-rebinding/lib.cjs @@ -0,0 +1,10 @@ +"use strict"; +function format(value) { + return "[" + value + "]"; +} +function shout(value) { + return String(value).toUpperCase(); +} +exports = module.exports = format; +exports.shout = shout; +exports.version = "1.2.3"; diff --git a/tests/corpus/6431-cjs-exports-rebinding/main.cjs b/tests/corpus/6431-cjs-exports-rebinding/main.cjs new file mode 100644 index 000000000..5c52ff687 --- /dev/null +++ b/tests/corpus/6431-cjs-exports-rebinding/main.cjs @@ -0,0 +1,6 @@ +// `exports = module.exports = value` rebinds the wrapper's exports binding +// too, so members attached through `exports` afterwards are exported. +const format = require("./lib.cjs"); +const { shout, version } = require("./lib.cjs"); +console.log(format("a"), shout("b"), version); +console.log(typeof format, typeof shout); From d04b7a5cf2e30fa875cb5bd3bcd52ec3a45d3463 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 21:59:15 -0500 Subject: [PATCH 42/85] Read ThreadSlots values with the layout they are stored with --- .../src/frontend/lowering/thread-slots.ts | 9 +++- .../5730-thread-slots-widened-values.ts.json | 6 +++ .../5730-thread-slots-widened-values.ts | 45 +++++++++++++++++++ tests/diagnostics/thread-slots-fences.ts | 5 +++ .../__snapshots__/thread-slots-fences.ts.txt | 11 ++++- .../5730-thread-slots-widened-values.ts.json | 1 + 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5730-thread-slots-widened-values.ts.json create mode 100644 tests/corpus/5730-thread-slots-widened-values.ts create mode 100644 tests/harness/corpus-costs/5730-thread-slots-widened-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/thread-slots.ts b/packages/compiler/src/frontend/lowering/thread-slots.ts index 50b5ecf09..8227c6bd0 100644 --- a/packages/compiler/src/frontend/lowering/thread-slots.ts +++ b/packages/compiler/src/frontend/lowering/thread-slots.ts @@ -22,6 +22,7 @@ import { threadSlotsMemberOf, } from "../experimental-import.js"; import * as ts from "../ts7/adapter.js"; +import { withUndefinedArm } from "../type-mapper.js"; import type { ClassInfo } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; import { locOf } from "../program.js"; @@ -238,10 +239,16 @@ export function lowerThreadSlotsCall(lowerer: Lowerer, expr: ts.CallExpression): const args = [name, receiver, key]; switch (member) { case "get": { + // The slots hold values in the layout of the type the constructor registered (`family.value`, + // which `set` stores with). The receiver's annotation can name a structurally wider value + // type, so the value read is converted to the call's type like any value flowing into it. const resultTs = lowerer.typeOf(expr); const result = lowerer.mapTypeOf(resultTs); if (result === null) lowerer.badType(expr, resultTs); - return { kind: "intrinsic", name: "slots.get", args, type: result!, loc }; + const stored = withUndefinedArm(family.value, lowerer.unions); + if (stored === null) lowerer.badType(expr, resultTs); + const read: IrExpr = { kind: "intrinsic", name: "slots.get", args, type: stored!, loc }; + return typeKey(stored!) === typeKey(result!) ? read : lowerer.coerceInto(expr, read, result!); } case "has": case "delete": diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5730-thread-slots-widened-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5730-thread-slots-widened-values.ts.json new file mode 100644 index 000000000..001c7ea32 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5730-thread-slots-widened-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5730-thread-slots-widened-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5730-thread-slots-widened-values.ts b/tests/corpus/5730-thread-slots-widened-values.ts new file mode 100644 index 000000000..88a1803e7 --- /dev/null +++ b/tests/corpus/5730-thread-slots-widened-values.ts @@ -0,0 +1,45 @@ +import { ThreadSlots } from "@scriptc/experimental"; + +// A ThreadSlots const annotated with a structurally wider value type than +// its constructor's: the values are stored with the constructor's layout and +// read back through the annotation's. Node.js prints the same. + +class Key { + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +interface Counted { + count: number; + label: string; +} + +interface Labeled { + label: string; +} + +interface Tagged { + label: string; + tag?: string; +} + +const counted: ThreadSlots = new ThreadSlots("widened.counted"); +const tagged: ThreadSlots = new ThreadSlots("widened.tagged"); + +const first = new Key(1); +const second = new Key(2); +const value: Counted = { count: 7, label: "seven" }; +counted.set(first, value); +const read = counted.get(first)!; +console.log(read.label, counted.get(second), counted.has(first)); + +tagged.set(second, { count: 2, label: "two" } as Counted); +const t = tagged.get(second); +console.log(t?.label, t?.tag, tagged.get(first)); + +function labelOf(slots: Labeled | undefined): string { + return slots === undefined ? "none" : slots.label; +} +console.log(labelOf(counted.get(first)), labelOf(counted.get(second))); diff --git a/tests/diagnostics/thread-slots-fences.ts b/tests/diagnostics/thread-slots-fences.ts index 7ec71db34..ed985c803 100644 --- a/tests/diagnostics/thread-slots-fences.ts +++ b/tests/diagnostics/thread-slots-fences.ts @@ -27,3 +27,8 @@ function local(slots: ThreadSlots, key: Key): State | undefined { const ctor = ThreadSlots; const getter = first.get; console.log(named, second, shaped, callbacks, local(first, new Key()), ctor, getter); + +// A read through an annotated value type the stored values can't be converted +// to: they are stored as records, which a read as the class can't convert. +const records: ThreadSlots = new ThreadSlots("fences.records"); +console.log(records.get(new Key())); diff --git a/tests/harness/__snapshots__/thread-slots-fences.ts.txt b/tests/harness/__snapshots__/thread-slots-fences.ts.txt index 566ddfba6..9d4648c27 100644 --- a/tests/harness/__snapshots__/thread-slots-fences.ts.txt +++ b/tests/harness/__snapshots__/thread-slots-fences.ts.txt @@ -59,4 +59,13 @@ thread-slots-fences.ts:28:16 - error SC2020: 'ThreadSlots.get (@scriptc/experime | ^~~~~~~~~ 29 | console.log(named, second, shaped, callbacks, local(first, new Key()), ctor, getter); - hint: call slots.get(...) directly \ No newline at end of file + hint: call slots.get(...) directly + +thread-slots-fences.ts:34:13 - error SC2003: union conversion has no unambiguous layout mapping: expected 'State | undefined', got '{ count: number } | undefined' (each source arm needs an identical destination arm, a unique compatible record/array layout, or a shared literal discriminant selecting compatible layouts) + + 33 | const records: ThreadSlots = new ThreadSlots("fences.records"); + 34 | console.log(records.get(new Key())); + | ^~~~~~~~~~~~~~~~~~~~~~ + 35 | + + hint: narrow the value to a SINGLE arm first (a discriminant check, or '!== undefined'/'!== null' for unit arms); a plain arm value widens into any union that contains it, and a whole union widens into any union whose arms include the source's \ No newline at end of file diff --git a/tests/harness/corpus-costs/5730-thread-slots-widened-values.ts.json b/tests/harness/corpus-costs/5730-thread-slots-widened-values.ts.json new file mode 100644 index 000000000..444a9b634 --- /dev/null +++ b/tests/harness/corpus-costs/5730-thread-slots-widened-values.ts.json @@ -0,0 +1 @@ +{ "plain": 2849, "san": 4164 } From 6dd82db8ff2eed564497b56c5bd78bde75b08092 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 21:59:58 -0500 Subject: [PATCH 43/85] Compile module exports that pass this on with apply or call --- .../frontend/lowering/module-member-this.ts | 18 +++++- tests/harness/errors.test.ts | 59 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/module-member-this.ts b/packages/compiler/src/frontend/lowering/module-member-this.ts index 205cc63e9..ce020d197 100644 --- a/packages/compiler/src/frontend/lowering/module-member-this.ts +++ b/packages/compiler/src/frontend/lowering/module-member-this.ts @@ -25,8 +25,19 @@ function exportedFunction(decl: ts.Node): ts.Node | null { return null; } +/** Whether `node` is `f.apply(this, ...)` or `f.call(this, ...)`, which + * passes the receiver on without reading it. */ +function forwardsThis(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node)) return false; + const callee = node.expression; + if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.name)) return false; + if (callee.name.text !== "apply" && callee.name.text !== "call") return false; + return node.arguments.nodes[0]?.kind === ts.SyntaxKind.ThisKeyword; +} + /** Whether a function's own body reads `this` (arrows share it; other - * functions and classes have their own). */ + * functions and classes have their own). A `this` that `.apply` or `.call` + * only passes on is not a read. */ function readsOwnThis(fn: ts.Node): boolean { let found = false; const visit = (node: ts.Node): void => { @@ -37,6 +48,11 @@ function readsOwnThis(fn: ts.Node): boolean { } if ((ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLikeDeclaration(node)) return; + if (forwardsThis(node)) { + visit(node.expression); + for (const arg of node.arguments.nodes.slice(1)) visit(arg); + return; + } node.forEachChild(visit); }; fn.forEachChild(visit); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 26dfd3a5c..6a779528d 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1150,6 +1150,65 @@ export function plain(x) { return x + 1; } /^Uncaught Error: calling a module export that reads 'this' through its module binding \('who': .*\) is not supported yet \[SC1090 at .*namespace-member-this\.mjs:3\]\n$/, ); }); + + test("a namespace import's export that passes this on with apply or call compiles", async () => { + // Effect 4's provideService: `dual(...).apply(this, arguments)` hands the + // receiver to a function that never reads it, as here. + const r = await compileAndRun( + "namespace-member-forwards-this", + `import * as ns from './lib.mjs'; +export const added = ns.provide(1, 2); +console.log(added, ns.sum(3, 4)); +`, + "mjs", + { + "lib.mjs": `const dual = (body) => function () { return body.apply(this, arguments); }; +export const provide = function () { return dual((a, b) => a + b).apply(this, arguments); }; +const twice = function (a, b) { return (a + b) * 2; }; +export const sum = function (a, b) { return twice.call(this, a, b); }; +`, + }, + ); + expect(r).toEqual({ stdout: "3 14\n", stderr: "", exitCode: 0 }); + }); + + test("a require binding's export that passes this on with apply compiles", async () => { + const r = await compileAndRun( + "require-member-forwards-this", + `'use strict'; +const m = require('./lib.cjs'); +console.log(m.scale(5)); +`, + "cjs", + { + "lib.cjs": `'use strict'; +const triple = function (x) { return x * 3; }; +exports.scale = function () { return triple.apply(this, arguments); }; +`, + }, + ); + expect(r).toEqual({ stdout: "15\n", stderr: "", exitCode: 0 }); + }); + + test("an export that also reads this beside passing it on is a named deferred fence", async () => { + const r = await compileAndRun( + "namespace-member-forwards-and-reads-this", + `import * as ns from './lib.mjs'; +console.log(ns.tag('x')); +`, + "mjs", + { + "lib.mjs": `const join = function (a, b) { return a + ':' + b; }; +export function tag(x) { return join.call(this, x, typeof this); } +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toMatch( + /^Uncaught Error: calling a module export that reads 'this' through its module binding \('tag': .*\) is not supported yet \[SC1090 at .*namespace-member-forwards-and-reads-this\.mjs:2\]\n$/, + ); + }); }); // CommonJS forms around the module's exports that keep a refusal name the From 2b33d25a7ed56d798c142a38ee072c7d5b31a46d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:03:11 -0500 Subject: [PATCH 44/85] Derive the runtime features from one table of rules ir/runtime-features.ts kept three parallel lists that every new runtime feature extended at the same place: the RuntimeFeatures interface fields, the all-false initializer, and the chain of libCall checks in the scan. They are now one table, RUNTIME_FEATURE_RULES, with one entry per feature, sorted by name. An entry lists what sets the feature: a module-level initial check, libCall names and name prefixes, libCall and expression predicates, and IR type kinds. A libCall or type that needs several units appears in each unit's entry. RuntimeFeatures, the scan and its early exit derive from the table; noRuntimeFeatures lists every feature once, and TypeScript rejects it when it misses one. Every corpus program gives the same features as before (3217 programs compared field by field on their IR), and the module still lowers statically when the compiler compiles itself. --- packages/compiler/src/ir/runtime-features.ts | 737 +++++++++---------- 1 file changed, 364 insertions(+), 373 deletions(-) diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 9bab3e79f..34ec76f8d 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -9,59 +9,6 @@ import { } from "./ir.js"; import { everyModuleNode } from "./traverse.js"; -export interface RuntimeFeatures { - regex: boolean; - copying: boolean; - legacyTextDecoder: boolean; - fileHandle: boolean; - fetch: boolean; - eventTarget: boolean; - processEvents: boolean; - /** process.emitWarning is assigned (process.setEmitWarning). Declared - * in the literal's order: JSON.stringify of the features record must - * match between the Node-run and self-hosted compilers. */ - warningReplace: boolean; - /** stdin setEncoding / string data listeners / stdio 'resize' events. */ - stdioExtended: boolean; - emitter: boolean; - stream: boolean; - zlib: boolean; - dc: boolean; - assert: boolean; - dynInvoke: boolean; - dynAsync: boolean; - /** The program has dynamic-tier jsval operations (js.* builtins, the - * jsval pass): links the scr_js_* units (two-tier design I3). */ - dynTier: boolean; - /** The program enters AsyncLocalStorage stores (store.run, enterWith, - * a diagnostics_channel store binding) or reads the test runner's - * context (getTestContext): its timer, immediate, tick and - * queueMicrotask scheduling goes through the runtime's *_als entry - * points, which carry the active stores into each callback. */ - asyncContext: boolean; - inspect: boolean; - childProcess: boolean; - net: boolean; - symbol: boolean; - bigint: boolean; - searchParams: boolean; - qs: boolean; - parseArgs: boolean; - fsWatch: boolean; - nodeTest: boolean; - /** The program calls mock.timers members: its timer calls go through - * scr_test.c's mock-aware entry points. */ - mockTimers: boolean; - dgram: boolean; - http: boolean; - http2: boolean; - tls: boolean; - tlsCa: boolean; - cipher: boolean; - /** crypto.subtle importKey/sign/verify/digest: scr_webcrypto.c. */ - webcrypto: boolean; -} - const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ // DEP0040 rides the dyn-async unit's warning machinery. "punycode.warn", @@ -311,9 +258,6 @@ const COPYING_BYTES_METHODS: ReadonlySet = new Set([ "with", ]); -const startsWithAny = (fn: string, prefixes: readonly string[]): boolean => - prefixes.some((prefix) => fn.startsWith(prefix)); - /** Inspect types and expressions together in one traversal. This is a fresh * snapshot: callers may mutate an IR module between compilation passes. */ /** The checked-dynamic undefined value (dynUndefinedExpr). */ @@ -393,315 +337,6 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set): boolean } } -/** Names that reach the extended process-stream surface (the - * SCR_STDIO_EXTENDED events unit): stdin.setEncoding and 'resize'. */ -const STDIO_EXTENDED_NAMES: ReadonlySet = new Set(["setEncoding", "resize"]); - -function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): RuntimeFeatures { - const features: RuntimeFeatures = { - regex: false, - copying: false, - legacyTextDecoder: false, - fileHandle: false, - fetch: false, - // Subclasses of the runtime EventTarget/Event bases tear down and box - // through the EventTarget unit even when no event.* libCall survives. - eventTarget: (mod.classes ?? []).some( - (c) => c.name === RUNTIME_EVENT_TARGET_CLASS || c.name === RUNTIME_EVENT_CLASS, - ), - processEvents: false, - warningReplace: false, - stdioExtended: false, - emitter: - mod.workers === true || (mod.classes ?? []).some((c) => c.name === RUNTIME_EMITTER_CLASS), - stream: (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), - zlib: false, - dc: false, - assert: false, - dynInvoke: mod.workers === true, - dynAsync: - mod.workers === true || - mod.functions.some( - (fn) => fn.async === true && fn.generator === undefined && fn.returnType.kind === "dyn", - ), - dynTier: false, - asyncContext: false, - inspect: false, - childProcess: false, - net: false, - // A Dirent row converted to dyn carries Node's Symbol(type) key - // (scr_dyn_dirent_finish, defined in scr_symbol.c). - symbol: (mod.records ?? []).some(isDirentShape), - bigint: false, - searchParams: false, - qs: false, - parseArgs: false, - fsWatch: false, - nodeTest: false, - mockTimers: false, - dgram: false, - http: false, - http2: false, - tls: false, - tlsCa: false, - cipher: false, - webcrypto: false, - }; - const keepGoing = (): boolean => stopAt === undefined || !features[stopAt]; - let tables: TypeTables | undefined; - const typeTables = (): TypeTables => - (tables ??= { - records: new Map((mod.records ?? []).map((r) => [r.id, r])), - unions: new Map((mod.unions ?? []).map((u) => [u.id, u])), - classes: new Map((mod.classes ?? []).map((c) => [c.name, c])), - }); - const expr = (node: IrExpr): boolean => { - if (node.kind === "libCall") { - const fn = node.fn; - if (fn.startsWith("js.")) features.dynTier = true; - if (REGEX_LIB_FNS.has(fn)) features.regex = true; - if (LEGACY_TEXT_DECODER_LIB_FNS.has(fn)) features.legacyTextDecoder = true; - if (fn.startsWith("fetch.")) features.fetch = true; - if (fn === "process.setEmitWarning") features.warningReplace = true; - // A typed string 'data' listener takes setEncoding-decoded chunks. - if ( - fn === "stdin.onData" && - node.args[0]?.type.kind === "func" && - node.args[0].type.params[0]?.kind === "string" - ) - features.stdioExtended = true; - if (fn.startsWith("event.") || fn === "events.addAbortListener") features.eventTarget = true; - if (ABORT_SIGNAL_LIB_FNS.has(fn)) features.eventTarget = true; - if (PROCESS_EVENT_LIB_FNS.has(fn)) features.processEvents = true; - // With a completer, readline's terminal unit prints completion errors - // through the inspect unit. - if (fn === "rl.terminal") { - features.regex = true; - if (!isDynUndefined(node.args[3]!)) features.inspect = true; - } - if (fn.startsWith("emitter.")) features.emitter = true; - if (STREAM_LIB_FNS.has(fn) || startsWithAny(fn, STREAM_LIB_PREFIXES)) { - // the stream unit emits through the emitter unit, also when the - // program constructs no stream (a pipeline of functions) - features.stream = true; - features.emitter = true; - } - if (fn.startsWith("zlib.")) features.zlib = true; - if (READABLE_DYN_ASYNC_LIB_FNS.has(fn) || startsWithAny(fn, READABLE_DYN_ASYNC_PREFIXES)) - features.dynAsync = true; - if (fn.startsWith("dc.")) features.dc = true; - if (fn.startsWith("assert.")) features.assert = true; - if (DYN_INVOKE_LIB_FNS.has(fn)) features.dynInvoke = true; - if (DYN_ASYNC_LIB_FNS.has(fn)) features.dynAsync = true; - if (ASYNC_CONTEXT_LIB_FNS.has(fn)) features.asyncContext = true; - if ( - fn.startsWith("insp.") || - fn.startsWith("console.") || - fn === "global.native" || - fn === "util.styleText" - ) - features.inspect = true; - if (CHILD_PROCESS_LIB_FNS.has(fn) || startsWithAny(fn, CHILD_PROCESS_LIB_PREFIXES)) - features.childProcess = true; - if (fn.startsWith("net.")) features.net = true; - if (fn.startsWith("http.")) { - features.net = true; - features.http = true; - } - if (fn.startsWith("tls.") || fn.startsWith("https.") || fn.startsWith("http2.")) { - features.net = true; - features.http = true; - features.tls = true; - } - if (fn.startsWith("http2.") && !HTTP2_LEGACY_FNS.has(fn)) features.http2 = true; - if (fn.startsWith("sym.") || fn.startsWith("disposable.") || SYMBOL_LIB_FNS.has(fn)) - features.symbol = true; - if (fn === "process.hrtimeValue") features.bigint = true; - if (fn.startsWith("bigint.")) features.bigint = true; - if (fn.startsWith("sp.") || fn === "url.searchParams") features.searchParams = true; - if (QS_LIB_FNS.has(fn)) features.qs = true; - // scr_url_legacy.c lowercases and normalizes hostnames (the regex - // unit's libunicode), parses and stringifies queries, and emits its - // deprecation warnings through the dyn-async unit. - if (LEGACY_URL_LIB_FNS.has(fn)) { - features.regex = true; - features.qs = true; - features.dynAsync = true; - } - if (PARSE_ARGS_LIB_FNS.has(fn)) features.parseArgs = true; - if (fn === "util.styleText") features.processEvents = true; - // scr_util_callable.c: dyn wrappers over the descriptor, inspect, - // regex (NODE_DEBUG matching) and terminal-color units. - if (UTIL_CALLABLE_LIB_FNS.has(fn)) { - features.parseArgs = true; - features.inspect = true; - features.dynInvoke = true; - features.regex = true; - } - if (fn.startsWith("fs.watch") || fn.startsWith("watcher.")) features.fsWatch = true; - // scr_test.c renders thrown strings in the failing-tests section with - // the inspect unit's string formatting. - if (fn.startsWith("test.")) { - features.nodeTest = true; - features.inspect = true; - if (fn.startsWith("test.mockTimers") || fn.startsWith("test.ctxMockTimers")) { - features.mockTimers = true; - } - } - if (fn.startsWith("dgram.") || fn.startsWith("dns.")) features.dgram = true; - if (fn.startsWith("tlsca.")) features.tlsCa = true; - // Locale case mapping runs the default conversion of the regex unit. - if (fn === "str.localeCase") features.regex = true; - // scr_cipher.c over the mbedTLS archive; DEP0182 reports through the - // process-warning unit, and unknown output encodings render through - // the inspect unit. - if (fn === "crypto.cipherNew") { - features.cipher = true; - features.dynAsync = true; - features.inspect = true; - } - // crypto.subtle and CryptoKey: scr_webcrypto.c. - if (fn.startsWith("webcrypto.")) features.webcrypto = true; - } else { - switch (node.kind) { - case "regexLit": - case "regexIntrinsic": - features.regex = true; - break; - case "strIntrinsic": - if (CASE_MAPPING_STR_METHODS.has(node.method)) features.regex = true; - break; - case "arrIntrinsic": - if (COPYING_ARR_METHODS.has(node.method)) features.copying = true; - break; - case "bytesIntrinsic": - if (COPYING_BYTES_METHODS.has(node.method)) features.copying = true; - break; - case "dynInvoke": - features.dynInvoke = true; - if (STDIO_EXTENDED_NAMES.has(node.method)) features.stdioExtended = true; - break; - // The process stream object's setEncoding and 'resize' members are - // reached by name (dynamic member calls and event-name strings). - case "strLit": - if (STDIO_EXTENDED_NAMES.has(node.value)) features.stdioExtended = true; - break; - case "awaitExpr": - if (node.type.kind === "dyn") features.dynAsync = true; - break; - case "intrinsic": - // Rejection reasons cross as dyn values (allSettled's records, - // any's AggregateError list). - if (node.name === "promise.allSettled" || node.name === "promise.any") - features.dynAsync = true; - break; - case "dynFrom": - if (!features.dynAsync && boxesPromise(node.value.type, typeTables(), new Set())) - features.dynAsync = true; - break; - } - } - return keepGoing(); - }; - const type = (node: IrType): boolean => { - switch (node.kind) { - case "regex": - features.regex = true; - break; - case "fileHandle": - features.fileHandle = true; - break; - case "procStream": - features.processEvents = true; - break; - case "child": - case "childStream": - case "childWriter": - case "spawnRes": - features.childProcess = true; - break; - case "netServer": - case "netSocket": - features.net = true; - break; - case "http2Session": - case "http2Stream": - features.net = true; - features.http2 = true; - break; - case "httpReq": - case "httpRes": - case "httpClientReq": - features.net = true; - features.http = true; - break; - case "secureCtx": - features.net = true; - features.http = true; - features.tls = true; - break; - case "symbol": - features.symbol = true; - break; - case "bigint": - features.bigint = true; - break; - case "searchParams": - features.searchParams = true; - break; - case "fsWatcher": - features.fsWatch = true; - break; - case "testCtx": - features.nodeTest = true; - break; - case "dgramSocket": - features.dgram = true; - break; - } - return keepGoing(); - }; - if (keepGoing()) everyModuleNode(mod, { expr, stmt: keepGoing, type }); - return features; -} - -/** True when the module contains any regex construct — a regexLit / - * regexIntrinsic node or a regex-typed slot anywhere. This is the link - * switch that pulls scr_regex.c + the vendored libregexp into the binary - * (native-toolchain.ts); regex-free programs keep the historical command line. The - * typed traversal visits type slots as well as executable nodes. */ -export function moduleUsesRegex(mod: IrModule): boolean { - return scanRuntimeFeatures(mod, "regex").regex; -} - -/** True when the module contains an intrinsic whose implementation lives - * in scr_copying.c. This is the link switch that keeps the optional - * Array-copying and typed-array bridge TU out of unrelated binaries. */ -export function moduleUsesCopying(mod: IrModule): boolean { - return scanRuntimeFeatures(mod, "copying").copying; -} - -/** True when a non-UTF-8 TextDecoder call survives lowering. This gates the - * generated legacy mapping tables inside scr_bytes.c; the default UTF-8 - * decoder and unrelated Buffer users keep their prior runtime object. */ -export function moduleUsesLegacyTextDecoder(mod: IrModule): boolean { - return scanRuntimeFeatures(mod, "legacyTextDecoder").legacyTextDecoder; -} - -/** True when a FileHandle type survives in the module. This is the link - * switch for scr_file_handle.c; fs/promises.open carries the type inside its - * promise result even when no handle method is called. */ -export function moduleUsesFileHandle(mod: IrModule): boolean { - return scanRuntimeFeatures(mod, "fileHandle").fileHandle; -} - -/** True when native fetch operations survive lowering. This links - * scr_fetch.c and its socket/tls/zlib dependencies and makes the emitted - * main call scr_fetch_install. Fetch-free graphs keep their link lines. */ -export function moduleUsesFetch(mod: IrModule): boolean { - return scanRuntimeFeatures(mod, "fetch").fetch; -} - /** The libCall fns served by the OPTIONAL events unit (scr_events.c): * process signal/exit listeners and the piped-stdin surface. */ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ @@ -760,6 +395,370 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "rl.uiCommit", ]); +/** The legacy http2.* libCalls implemented by scr_http.c/scr_tls.c (the + * allowHTTP1 compatibility slice) — they must NOT pull scr_http2.c, so + * divergence-57 binaries keep their exact link line. */ +const HTTP2_LEGACY_FNS: ReadonlySet = new Set([ + "http2.streamNoop", + "http2.streamUndefCall", +]); + +/** Names that reach the extended process-stream surface (the + * SCR_STDIO_EXTENDED events unit): stdin.setEncoding and 'resize'. */ +const STDIO_EXTENDED_NAMES: ReadonlySet = new Set(["setEncoding", "resize"]); + +/** The libCall nodes the rules below test. */ +type LibCallNode = IrExpr & { kind: "libCall" }; + +/** What makes a program need one optional runtime unit. The feature is set + * when any condition holds: `initial` from the module's declarations, a + * libCall named in `fns` or starting with one of `prefixes`, a libCall + * `libCall` accepts, another expression node `expr` accepts, or a type + * slot of one of the `types` kinds. A libCall or type that needs several + * units appears in each unit's rule. */ +interface RuntimeFeatureRule { + initial?: (mod: IrModule) => boolean; + fns?: readonly IrLibFn[]; + prefixes?: readonly string[]; + libCall?: (node: LibCallNode) => boolean; + expr?: (node: IrExpr, tables: () => TypeTables) => boolean; + types?: readonly IrType["kind"][]; +} + +/** Gives every rule in the table one type. */ +const rule = (r: RuntimeFeatureRule): RuntimeFeatureRule => r; + +/* The HTTP, HTTPS, HTTP/2 and TLS surfaces, which link the net unit. */ +const NET_PREFIXES: readonly string[] = ["net.", "http.", "tls.", "https.", "http2."]; + +/** One rule per runtime feature, sorted by name. A new feature is an + * entry here and its line in noRuntimeFeatures; RuntimeFeatures, the scan + * and its early exit derive from this table. */ +const RUNTIME_FEATURE_RULES = { + assert: rule({ prefixes: ["assert."] }), + /** The program enters AsyncLocalStorage stores (store.run, enterWith, + * a diagnostics_channel store binding) or reads the test runner's + * context (getTestContext): its timer, immediate, tick and + * queueMicrotask scheduling goes through the runtime's *_als entry + * points, which carry the active stores into each callback. */ + asyncContext: rule({ fns: [...ASYNC_CONTEXT_LIB_FNS] }), + bigint: rule({ fns: ["process.hrtimeValue"], prefixes: ["bigint."], types: ["bigint"] }), + childProcess: rule({ + fns: [...CHILD_PROCESS_LIB_FNS], + prefixes: CHILD_PROCESS_LIB_PREFIXES, + types: ["child", "childStream", "childWriter", "spawnRes"], + }), + /** scr_cipher.c over the mbedTLS archive; DEP0182 reports through the + * process-warning unit, and unknown output encodings render through the + * inspect unit. */ + cipher: rule({ fns: ["crypto.cipherNew"] }), + copying: rule({ + expr: (node, _tables) => + (node.kind === "arrIntrinsic" && COPYING_ARR_METHODS.has(node.method)) || + (node.kind === "bytesIntrinsic" && COPYING_BYTES_METHODS.has(node.method)), + }), + dc: rule({ prefixes: ["dc."] }), + /** dns.lookup lives in the dgram unit. */ + dgram: rule({ prefixes: ["dgram.", "dns."], types: ["dgramSocket"] }), + dynAsync: rule({ + initial: (mod) => + mod.workers === true || + mod.functions.some( + (fn) => fn.async === true && fn.generator === undefined && fn.returnType.kind === "dyn", + ), + // The legacy url functions and crypto.createCipheriv emit their + // deprecation warnings through the dyn-async unit. + fns: [ + ...DYN_ASYNC_LIB_FNS, + ...READABLE_DYN_ASYNC_LIB_FNS, + ...LEGACY_URL_LIB_FNS, + "crypto.cipherNew", + ], + prefixes: READABLE_DYN_ASYNC_PREFIXES, + expr: (node, tables) => + (node.kind === "awaitExpr" && node.type.kind === "dyn") || + // Rejection reasons cross as dyn values (allSettled's records, any's + // AggregateError list). + (node.kind === "intrinsic" && + (node.name === "promise.allSettled" || node.name === "promise.any")) || + (node.kind === "dynFrom" && boxesPromise(node.value.type, tables(), new Set())), + }), + dynInvoke: rule({ + initial: (mod) => mod.workers === true, + fns: [...DYN_INVOKE_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS], + expr: (node, _tables) => node.kind === "dynInvoke", + }), + /** The program has dynamic-tier jsval operations (js.* builtins, the + * jsval pass): links the scr_js_* units (two-tier design I3). */ + dynTier: rule({ prefixes: ["js."] }), + /** The stream unit emits through the emitter unit, also when the + * program constructs no stream (a pipeline of functions). */ + emitter: rule({ + initial: (mod) => + mod.workers === true || (mod.classes ?? []).some((c) => c.name === RUNTIME_EMITTER_CLASS), + fns: [...STREAM_LIB_FNS], + prefixes: ["emitter.", ...STREAM_LIB_PREFIXES], + }), + /** Subclasses of the runtime EventTarget/Event bases tear down and box + * through the EventTarget unit even when no event.* libCall survives. */ + eventTarget: rule({ + initial: (mod) => + (mod.classes ?? []).some( + (c) => c.name === RUNTIME_EVENT_TARGET_CLASS || c.name === RUNTIME_EVENT_CLASS, + ), + fns: ["events.addAbortListener", ...ABORT_SIGNAL_LIB_FNS], + prefixes: ["event."], + }), + fetch: rule({ prefixes: ["fetch."] }), + fileHandle: rule({ types: ["fileHandle"] }), + fsWatch: rule({ prefixes: ["fs.watch", "watcher."], types: ["fsWatcher"] }), + http: rule({ + prefixes: ["http.", "tls.", "https.", "http2."], + types: ["httpReq", "httpRes", "httpClientReq", "secureCtx"], + }), + /** The real h2 surface (scr_http2.c); the legacy http2.* libCalls of the + * allowHTTP1 slice are served by scr_http.c and scr_tls.c. */ + http2: rule({ + libCall: (node) => node.fn.startsWith("http2.") && !HTTP2_LEGACY_FNS.has(node.fn), + types: ["http2Session", "http2Stream"], + }), + /** scr_test.c renders thrown strings with the inspect unit's string + * formatting; scr_util_callable.c wraps the inspect unit; readline's + * terminal unit prints completion errors through it when there is a + * completer. */ + inspect: rule({ + fns: ["global.native", "util.styleText", "crypto.cipherNew", ...UTIL_CALLABLE_LIB_FNS], + prefixes: ["insp.", "console.", "test."], + libCall: (node) => node.fn === "rl.terminal" && !isDynUndefined(node.args[3]!), + }), + legacyTextDecoder: rule({ fns: [...LEGACY_TEXT_DECODER_LIB_FNS] }), + /** The program calls mock.timers members: its timer calls go through + * scr_test.c's mock-aware entry points. */ + mockTimers: rule({ prefixes: ["test.mockTimers", "test.ctxMockTimers"] }), + net: rule({ + prefixes: NET_PREFIXES, + types: [ + "netServer", + "netSocket", + "http2Session", + "http2Stream", + "httpReq", + "httpRes", + "httpClientReq", + "secureCtx", + ], + }), + nodeTest: rule({ prefixes: ["test."], types: ["testCtx"] }), + /** scr_util_callable.c reads options through the parseArgs unit's + * descriptors. */ + parseArgs: rule({ fns: [...PARSE_ARGS_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS] }), + processEvents: rule({ fns: [...PROCESS_EVENT_LIB_FNS, "util.styleText"], types: ["procStream"] }), + /** scr_url_legacy.c parses and stringifies queries. */ + qs: rule({ fns: [...QS_LIB_FNS, ...LEGACY_URL_LIB_FNS] }), + /** scr_url_legacy.c lowercases and normalizes hostnames with the regex + * unit's libunicode, NODE_DEBUG matching in scr_util_callable.c and + * readline's terminal unit use the regex unit, and locale case mapping + * runs its default conversion. */ + regex: rule({ + fns: [ + ...REGEX_LIB_FNS, + ...LEGACY_URL_LIB_FNS, + ...UTIL_CALLABLE_LIB_FNS, + "rl.terminal", + "str.localeCase", + ], + expr: (node, _tables) => + node.kind === "regexLit" || + node.kind === "regexIntrinsic" || + (node.kind === "strIntrinsic" && CASE_MAPPING_STR_METHODS.has(node.method)), + types: ["regex"], + }), + searchParams: rule({ fns: ["url.searchParams"], prefixes: ["sp."], types: ["searchParams"] }), + /** stdin setEncoding / string data listeners / stdio 'resize' events. A + * typed string 'data' listener takes setEncoding-decoded chunks; the + * process stream object's setEncoding and 'resize' members are reached + * by name (dynamic member calls and event-name strings). */ + stdioExtended: rule({ + libCall: (node) => + node.fn === "stdin.onData" && + node.args[0]?.type.kind === "func" && + node.args[0].type.params[0]?.kind === "string", + expr: (node, _tables) => + (node.kind === "dynInvoke" && STDIO_EXTENDED_NAMES.has(node.method)) || + (node.kind === "strLit" && STDIO_EXTENDED_NAMES.has(node.value)), + }), + stream: rule({ + initial: (mod) => (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), + fns: [...STREAM_LIB_FNS], + prefixes: STREAM_LIB_PREFIXES, + }), + /** A Dirent row converted to dyn carries Node's Symbol(type) key + * (scr_dyn_dirent_finish, defined in scr_symbol.c). */ + symbol: rule({ + initial: (mod) => (mod.records ?? []).some(isDirentShape), + fns: [...SYMBOL_LIB_FNS], + prefixes: ["sym.", "disposable."], + types: ["symbol"], + }), + tls: rule({ prefixes: ["tls.", "https.", "http2."], types: ["secureCtx"] }), + tlsCa: rule({ prefixes: ["tlsca."] }), + /** process.emitWarning is assigned (process.setEmitWarning). */ + warningReplace: rule({ fns: ["process.setEmitWarning"] }), + /** crypto.subtle importKey/sign/verify/digest and CryptoKey: + * scr_webcrypto.c. */ + webcrypto: rule({ prefixes: ["webcrypto."] }), + zlib: rule({ prefixes: ["zlib."] }), +}; + +type RuntimeFeatureName = keyof typeof RUNTIME_FEATURE_RULES; + +export type RuntimeFeatures = Record; + +const RUNTIME_FEATURE_NAMES: RuntimeFeatureName[] = []; +for (const name in RUNTIME_FEATURE_RULES) RUNTIME_FEATURE_NAMES.push(name as RuntimeFeatureName); + +/** Every feature off, in the table's order. TypeScript rejects this + * literal when it misses a feature or names one without a rule. */ +function noRuntimeFeatures(): RuntimeFeatures { + return { + assert: false, + asyncContext: false, + bigint: false, + childProcess: false, + cipher: false, + copying: false, + dc: false, + dgram: false, + dynAsync: false, + dynInvoke: false, + dynTier: false, + emitter: false, + eventTarget: false, + fetch: false, + fileHandle: false, + fsWatch: false, + http: false, + http2: false, + inspect: false, + legacyTextDecoder: false, + mockTimers: false, + net: false, + nodeTest: false, + parseArgs: false, + processEvents: false, + qs: false, + regex: false, + searchParams: false, + stdioExtended: false, + stream: false, + symbol: false, + tls: false, + tlsCa: false, + warningReplace: false, + webcrypto: false, + zlib: false, + }; +} + +const ruleOf = (name: RuntimeFeatureName): RuntimeFeatureRule => RUNTIME_FEATURE_RULES[name]; + +/** The rules indexed for the scan: features by libCall name and by type + * kind, and the prefix and predicate conditions in rule order. */ +const FEATURES_BY_FN = new Map(); +const FEATURES_BY_TYPE = new Map(); +const FEATURE_PREFIXES: { prefix: string; name: RuntimeFeatureName }[] = []; +const FEATURE_LIBCALL_TESTS: RuntimeFeatureName[] = []; +const FEATURE_EXPR_TESTS: RuntimeFeatureName[] = []; +for (const name of RUNTIME_FEATURE_NAMES) { + const rule = ruleOf(name); + for (const fn of rule.fns ?? []) { + const names = FEATURES_BY_FN.get(fn); + if (names) names.push(name); + else FEATURES_BY_FN.set(fn, [name]); + } + for (const kind of rule.types ?? []) { + const names = FEATURES_BY_TYPE.get(kind); + if (names) names.push(name); + else FEATURES_BY_TYPE.set(kind, [name]); + } + for (const prefix of rule.prefixes ?? []) FEATURE_PREFIXES.push({ prefix, name }); + if (rule.libCall) FEATURE_LIBCALL_TESTS.push(name); + if (rule.expr) FEATURE_EXPR_TESTS.push(name); +} + +function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): RuntimeFeatures { + const features = noRuntimeFeatures(); + for (const name of RUNTIME_FEATURE_NAMES) { + const initial = ruleOf(name).initial; + if (initial && initial(mod)) features[name] = true; + } + const keepGoing = (): boolean => stopAt === undefined || !features[stopAt]; + let tables: TypeTables | undefined; + const typeTables = (): TypeTables => + (tables ??= { + records: new Map((mod.records ?? []).map((r) => [r.id, r])), + unions: new Map((mod.unions ?? []).map((u) => [u.id, u])), + classes: new Map((mod.classes ?? []).map((c) => [c.name, c])), + }); + const expr = (node: IrExpr): boolean => { + if (node.kind === "libCall") { + const fn = node.fn; + for (const name of FEATURES_BY_FN.get(fn) ?? []) features[name] = true; + for (const { prefix, name } of FEATURE_PREFIXES) + if (!features[name] && fn.startsWith(prefix)) features[name] = true; + for (const name of FEATURE_LIBCALL_TESTS) + if (!features[name] && ruleOf(name).libCall!(node)) features[name] = true; + } else { + for (const name of FEATURE_EXPR_TESTS) + if (!features[name] && ruleOf(name).expr!(node, typeTables)) features[name] = true; + } + return keepGoing(); + }; + const type = (node: IrType): boolean => { + for (const name of FEATURES_BY_TYPE.get(node.kind) ?? []) features[name] = true; + return keepGoing(); + }; + if (keepGoing()) everyModuleNode(mod, { expr, stmt: keepGoing, type }); + return features; +} + +/** True when the module contains any regex construct — a regexLit / + * regexIntrinsic node or a regex-typed slot anywhere. This is the link + * switch that pulls scr_regex.c + the vendored libregexp into the binary + * (native-toolchain.ts); regex-free programs keep the historical command line. The + * typed traversal visits type slots as well as executable nodes. */ +export function moduleUsesRegex(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "regex").regex; +} + +/** True when the module contains an intrinsic whose implementation lives + * in scr_copying.c. This is the link switch that keeps the optional + * Array-copying and typed-array bridge TU out of unrelated binaries. */ +export function moduleUsesCopying(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "copying").copying; +} + +/** True when a non-UTF-8 TextDecoder call survives lowering. This gates the + * generated legacy mapping tables inside scr_bytes.c; the default UTF-8 + * decoder and unrelated Buffer users keep their prior runtime object. */ +export function moduleUsesLegacyTextDecoder(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "legacyTextDecoder").legacyTextDecoder; +} + +/** True when a FileHandle type survives in the module. This is the link + * switch for scr_file_handle.c; fs/promises.open carries the type inside its + * promise result even when no handle method is called. */ +export function moduleUsesFileHandle(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "fileHandle").fileHandle; +} + +/** True when native fetch operations survive lowering. This links + * scr_fetch.c and its socket/tls/zlib dependencies and makes the emitted + * main call scr_fetch_install. Fetch-free graphs keep their link lines. */ +export function moduleUsesFetch(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "fetch").fetch; +} + /** True when the module uses the process-events surface — the link switch * that pulls scr_events.c into the binary and has the emitted main call * scr_events_install (native-toolchain.ts + emitter; the scr_regex/scr_fetch/scr_zlib @@ -949,14 +948,6 @@ export function moduleUsesHttpServer(mod: IrModule): boolean { return scanRuntimeFeatures(mod, "http").http; } -/** The legacy http2.* libCalls implemented by scr_http.c/scr_tls.c (the - * allowHTTP1 compatibility slice) — they must NOT pull scr_http2.c, so - * divergence-57 binaries keep their exact link line. */ -const HTTP2_LEGACY_FNS: ReadonlySet = new Set([ - "http2.streamNoop", - "http2.streamUndefCall", -]); - /** True when the module uses the REAL h2 surface (scr_http2.c): any core * http2.* libCall, or an h2 handle type left behind by a fenced statement * (its emitted release call needs the unit — the moduleUsesNet story). */ From 1af7a862ce12e1cd16e5b48e7b2a0d2b4cc6a72b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:05:25 -0500 Subject: [PATCH 45/85] Keep stored cycle functions and self-referencing helper copies refused Two fixes for the CommonJS cycle check and the bundled-chunk splitter: - During a cycle's initialization, a read of a partner's exported function is safe only where the value neither runs nor stays: an expression statement, typeof, void, !, an equality operand, or the test of if, while, do and ?:. A const alias (`const f = binding.fn`), an object or array literal, a return, or an argument can be called before the cycle settles, so these keep SC1016 (Node throws when the function reads a binding in its temporal dead zone). A top-level const that only publishes the function in an export statement, as the static rewrite spells an export-table re-export, stays a read. - A copied helper whose initializer is a named function expression reaches itself through its own name. Each chunk gets its own copy, so that name must also be used only as a callee; otherwise the chunk keeps the package refused with the existing reason. --- .../npm-static-bundled-chunks.test.ts | 32 ++++++ .../src/frontend/npm-static-bundled-chunks.ts | 30 ++++- packages/compiler/src/frontend/program.ts | 103 ++++++++++++++---- .../main.ts.json | 6 + .../2571-cjs-cycle-stored-function/main.ts | 8 ++ .../node_modules/gtcyclealias/dist/index.d.ts | 3 + .../node_modules/gtcyclealias/dist/index.js | 37 +++++++ .../node_modules/gtcyclealias/dist/reader.js | 34 ++++++ .../node_modules/gtcyclealias/package.json | 7 ++ .../node_modules/gtcyclebox/dist/index.d.ts | 3 + .../npm/node_modules/gtcyclebox/dist/index.js | 37 +++++++ .../node_modules/gtcyclebox/dist/reader.js | 34 ++++++ .../npm/node_modules/gtcyclebox/package.json | 7 ++ tests/harness/npm-cases.ts | 4 +- tests/harness/npm-static.test.ts | 17 ++- 15 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json create mode 100644 tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts create mode 100644 tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclealias/package.json create mode 100644 tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js create mode 100644 tests/fixtures/npm/node_modules/gtcyclebox/package.json diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts index fe54681d7..f9463ea1e 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -150,11 +150,43 @@ console.log(e.outer, e.indirect, e.wrapped, e.nested, e.seen.join(",")); expect(chunk).toContain("function wrap(n)"); }); +test("a named function expression that only calls itself travels with a chunk", () => { + const answer = expectSameBehavior(`${keyedHelper}var fact = function self(n) { + return n <= 1 ? 1 : n * self(n - 1); +}; +var kind = function self(self) { + return typeof self; +}; +var outer = function self() { + var self = 2; + return self; +}; +var require_g = __commonJS({ + "g.js"(exports2, module2) { + module2.exports = [fact(5), kind(1), outer()]; + } +}); +console.log(require_g().join(",")); +`); + const chunk = answer.chunks[0]!.source; + expect(chunk).toContain("var fact = function self(n)"); +}); + test.each([ ["exports it", `function tally() {}`, `module2.exports = { tally };`], ["reads its properties", `function tally() {}`, `exports2.calls = tally.calls;`], ["constructs it", `function tally() {}`, `exports2.made = new tally();`], ["stores an arrow helper", `var tally = () => 1;`, `exports2.fn = tally;`], + [ + "calls a function that reads its own name as a value", + `var tally = function self() { self.n = (self.n || 0) + 1; return self.n; };`, + `exports2.count = tally();`, + ], + [ + "calls a function whose parameter default reads its own name", + `var tally = function self(box = self) { box.n = (box.n || 0) + 1; return box.n; };`, + `exports2.count = tally();`, + ], ])("a chunk that %s keeps the package refused", (_, declaration, statement) => { const result = split(`${keyedHelper}${declaration} var require_f = __commonJS({ diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index abc86bcbd..276079494 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -209,6 +209,15 @@ class BundleScopes { return false; } + /** True when a scope inside `root` (exclusive) binds the reference's + * name. */ + boundInside(id: ts.Identifier, root: ts.Node): boolean { + for (let n = id.parent; n !== undefined && n !== root; n = n.parent) { + if (this.of(n).has(id.text)) return true; + } + return false; + } + /** True when the reference resolves to a top-level binding of `sf`. */ topLevel(id: ts.Identifier, sf: ts.SourceFile): boolean { for (let n = id.parent; n !== undefined && n !== sf; n = n.parent) { @@ -350,6 +359,25 @@ function isFunctionValued(node: ts.VariableDeclaration | ts.FunctionDeclaration) return init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)); } +/** True when a named function expression initializer (`var f = function + * self() {}`) uses its own name only as a callee: `self` is the copied + * function, so it is as observable as the top-level binding. */ +function selfCalledOnly( + node: ts.VariableDeclaration | ts.FunctionDeclaration, + scopes: BundleScopes, +): boolean { + if (!ts.isVariableDeclaration(node) || node.initializer === undefined) return true; + const fn = unparen(node.initializer); + if (!ts.isFunctionExpression(fn) || fn.name === undefined || fn.body === undefined) return true; + const self = fn.name.text; + // A parameter or `var` of the same name hides the function's own name. + const own = varScopedNames(fn.body); + for (const param of fn.parameters.nodes) addBindingNames(param.name, own); + if (own.has(self)) return true; + // Its parameter initializers see the name too. + return references(fn).every((r) => r.text !== self || scopes.boundInside(r, fn) || isCallee(r)); +} + /** True when a reference is the function a call invokes: `f(...)`, or the * `(0, f)(...)` form that drops the receiver. */ function isCallee(id: ts.Identifier): boolean { @@ -748,7 +776,7 @@ function chunkSource( return why(`reads the bundle's top-level binding '${name}', which it cannot carry along`); // Each chunk gets its own copy, so a function's identity and its // properties must stay unobservable: the bundle only ever calls it. - if (isFunctionValued(node) && !calledOnly(name)) + if (isFunctionValued(node) && (!calledOnly(name) || !selfCalledOnly(node, scopes))) return why( `uses the bundle's top-level function '${name}' as a value, which its chunks cannot share`, ); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 12d9b703a..79978abee 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3199,11 +3199,66 @@ function cjsCycleRefusal7( return null; } -/** True for a plain read `binding.name` (not a call, not a write) of the - * cycle member `dep` whose export `name` is a top-level function - * declaration nothing reassigns. Every member settles its exports before - * entering the cycle, so Node reads that same function whenever the read - * runs, and the compiled read resolves to the declaration itself. */ +/** True when `use` initializes a top-level const whose every other + * reference is a value of an export statement (`module.exports = { … }` + * or `exports.n = …`). */ +function publishedAlias7(use: ts.Node, decl: ts.Node | undefined): boolean { + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + decl.initializer !== use || + !ts.isIdentifier(decl.name) + ) + return false; + const list = decl.parent; + const stmt = list?.parent; + const sf = stmt?.parent; + if ( + list === undefined || + (list.flags & ts.NodeFlags.Const) === 0 || + stmt === undefined || + !ts.isVariableStatement(stmt) || + sf === undefined || + !ts.isSourceFile(sf) + ) + return false; + const alias = decl.name; + const published = new Set(); + for (const st of sf.statements.nodes) { + const cjs = cjsExportAssignmentOf(st); + if (cjs === null) continue; + if (cjs.kind === "member") published.add(cjs.value); + else if (cjs.obj !== null) { + for (const p of cjs.obj.properties.nodes) { + if (ts.isPropertyAssignment(p) && !ts.isComputedPropertyName(p.name)) + published.add(p.initializer); + else if (ts.isShorthandPropertyAssignment(p)) published.add(p.name); + } + } + } + let kept = false; + ts.walkPreorder(sf, (n) => { + if (kept) return "stop"; + if (!ts.isIdentifier(n) || n === alias || n.text !== alias.text) return undefined; + const p = n.parent; + if ( + p !== undefined && + ((ts.isPropertyAccessExpression(p) && p.name === n) || + (ts.isPropertyAssignment(p) && p.name === n)) + ) + return undefined; + if (!published.has(n)) kept = true; + return undefined; + }); + return !kept; +} + +/** True for a plain read `binding.name` (not a call, not a write, not a + * value that is stored or passed on) of the cycle member `dep` whose + * export `name` is a top-level function declaration nothing reassigns. + * Every member settles its exports before entering the cycle, so Node + * reads that same function whenever the read runs, and the compiled read + * resolves to the declaration itself. */ function functionExportRead7(id: ts.Identifier, dep: ts.SourceFile): boolean { const access = id.parent; if (!ts.isPropertyAccessExpression(access) || access.expression !== id) return false; @@ -3222,25 +3277,27 @@ function functionExportRead7(id: ts.Identifier, dep: ts.SourceFile): boolean { else break; } const parent = use.parent; - // A use that can run the function (a call, `new`, a tag, a member of it - // such as `.call`, or an argument another call receives) is not a read. - if ( - (ts.isCallExpression(parent) && parent.expression === use) || - (ts.isCallExpression(parent) && parent.arguments.nodes.includes(use as ts.Expression)) || - ts.isNewExpression(parent) || - (ts.isTaggedTemplateExpression(parent) && parent.tag === use) || - (ts.isPropertyAccessExpression(parent) && parent.expression === use) || - (ts.isElementAccessExpression(parent) && parent.expression === use) || - ts.isDeleteExpression(parent) || - ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && - (parent.operator === ts.SyntaxKind.PlusPlusToken || - parent.operator === ts.SyntaxKind.MinusMinusToken)) || + // Only a use that neither runs the function nor keeps it is a read: a + // stored alias (`const f = binding.name`, `{ f: binding.name }`) or an + // argument can be called while the cycle initializes. A top-level const + // that only publishes it (`module.exports = { name: f }`, the static + // rewrite's re-export spelling) runs nothing: a module that reads the + // export back checks that read itself. + const observes = + publishedAlias7(use, parent) || + ts.isExpressionStatement(parent) || + ts.isTypeOfExpression(parent) || + ts.isVoidExpression(parent) || + (ts.isPrefixUnaryExpression(parent) && parent.operator === ts.SyntaxKind.ExclamationToken) || (ts.isBinaryExpression(parent) && - parent.left === use && - parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) - ) - return false; + (parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken)) || + ((ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) && + parent.expression === use) || + (ts.isConditionalExpression(parent) && parent.condition === use); + if (!observes) return false; const name = access.name.text; // The exported value's identifier: a table entry or a member export. const values: ts.Expression[] = []; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json new file mode 100644 index 000000000..c3e0442f9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts b/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts new file mode 100644 index 000000000..ae2be341a --- /dev/null +++ b/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts @@ -0,0 +1,8 @@ +// Require cycles whose partner stores an export of index.js (in a const, +// or in an object literal) and calls it while index is still +// initializing: the export reads a const in its temporal dead zone, so +// Node throws a ReferenceError, and scriptc refuses both cycles. +import { show } from "gtcyclealias"; +import { show as showBox } from "gtcyclebox"; + +console.log(show(), showBox()); diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts new file mode 100644 index 000000000..5f4373e2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts @@ -0,0 +1,3 @@ +export declare const LIMIT = 3; +export declare function show(): string; +export declare function peek(): number; diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js new file mode 100644 index 000000000..77aca3c9b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js @@ -0,0 +1,37 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + peek: () => peek, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function peek() { + return LIMIT; +} +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js new file mode 100644 index 000000000..943e61a0a --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js @@ -0,0 +1,34 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const read = import_index.peek; +const captured = read(); +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/package.json b/tests/fixtures/npm/node_modules/gtcyclealias/package.json new file mode 100644 index 000000000..9ee7441e0 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcyclealias", + "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output shape of a cycle whose partner stores an exported function in a const and calls it while that function's const binding is uninitialized", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts new file mode 100644 index 000000000..5f4373e2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts @@ -0,0 +1,3 @@ +export declare const LIMIT = 3; +export declare function show(): string; +export declare function peek(): number; diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js new file mode 100644 index 000000000..77aca3c9b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js @@ -0,0 +1,37 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + peek: () => peek, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function peek() { + return LIMIT; +} +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js new file mode 100644 index 000000000..b8f8a927e --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js @@ -0,0 +1,34 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const box = { read: import_index.peek }; +const captured = box.read(); +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/package.json b/tests/fixtures/npm/node_modules/gtcyclebox/package.json new file mode 100644 index 000000000..67f943195 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcyclebox", + "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output shape of a cycle whose partner stores an exported function in an object literal and calls it while that function's const binding is uninitialized", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 451441009..0830d4162 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,12 +19,12 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469 and 2556-2566 are the --npm-static bundler-emitted-CJS + // 2465-2469, 2556-2566 and 2571 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-24-6])-[^/]+\/main\.ts$/.test(entry)) + .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-24-6]|2571)-[^/]+\/main\.ts$/.test(entry)) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 4930487e2..8325e2998 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,7 +76,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2566) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2566, 2571) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ]; for (const f of inputs) hash.update(f).update(readFileSync(f)); @@ -1482,6 +1482,21 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 120_000); + // 2571: the partner stores the export (in a const, or in an object + // literal) and calls it while the cycle initializes; both keep SC1016. + test("a CommonJS cycle that stores an export and calls it stays refused", () => { + const entry = join(fixturesRoot, "npm/cases/2571-cjs-cycle-stored-function/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtcyclealias", "gtcyclebox"] }); + for (const pkg of ["gtcyclealias", "gtcyclebox"]) + expect(coverage.npmStatic).toContainEqual({ + package: pkg, + status: "refused", + detail: expect.stringMatching( + /circular imports .*can use its binding 'import_index' of a module of the cycle/, + ) as string, + }); + }, 120_000); + // 2564: a chunk reads a top-level function whose name a nested scope of // the chunk also declares; the function still travels with the chunk. test("a chunk carries a top-level function its nested scopes shadow", async () => { From 1d007024efad2f9a4134ccaba5641c2eb910e002 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:06:09 -0500 Subject: [PATCH 46/85] List the executable's optional runtime units and defines in one table each compileCInternal built the program's command line from a chain of conditional spreads, one per optional runtime unit, and wrote the optional -D flags out twice (the program build and the runtime-object flags). RUNTIME_UNITS now lists the optional runtime inputs in link order, one entry per unit: the sources and the condition that selects them, or a small function for the entries that add vendor objects, include directories or platform libraries. RUNTIME_DEFINES lists the optional definitions once, and both flag lists use it. The command lines keep their order, so the executables do not change. The comment fragment before the process-events unit, left from a removed zlib entry, is dropped. --- .../compiler/src/backend/native/executable.ts | 378 +++++++++++------- 1 file changed, 230 insertions(+), 148 deletions(-) diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index e632795c9..5ebb4796e 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -98,6 +98,218 @@ import { CacheInputsChangedError } from "./session.js"; import { ensureRuntimeObjects, stageRuntimeObjects, clearCcCaches } from "./runtime-objects.js"; import { localizeLibraryObjects } from "./object-merge.js"; +/** What the runtime-unit table reads: the build options, the target driver, + * the units other options imply, and the vendor inputs resolved for this + * build. */ +interface RuntimeUnitContext { + opts: CcOptions; + driver: ReturnType; + rt: (path: string) => string; + rtDir: string; + regex: boolean; + nativeFetch: boolean; + net: boolean; + http: boolean; + tls: boolean; + tlsCa: boolean; + cipher: boolean; + lreObjects: readonly string[]; + zlibObjects: readonly string[]; + tlsArchive: string | null; +} + +/** An optional runtime input: runtime sources compiled when `when` holds, + * or command-line arguments computed from the build. */ +interface RuntimeUnit { + when: (u: RuntimeUnitContext) => boolean | undefined; + sources: readonly string[]; + args?: (u: RuntimeUnitContext) => readonly string[]; +} + +const unit = (when: RuntimeUnit["when"], ...sources: string[]): RuntimeUnit => ({ when, sources }); +const unitArgs = (args: (u: RuntimeUnitContext) => readonly string[]): RuntimeUnit => ({ + when: () => true, + sources: [], + args, +}); + +/** The optional runtime inputs, in link order. The order is part of the + * executable: a new unit goes next to the units it references. */ +const RUNTIME_UNITS: readonly RuntimeUnit[] = [ + unit( + (u) => u.opts.workers, + "scr_worker.c", + "scr_worker_events.c", + "scr_mailbox.c", + "scr_message.c", + ), + unit((u) => u.opts.copying, "scr_copying.c"), + unit((u) => u.opts.fileHandle, "scr_file_handle.c"), + // win32 targets compile the libc-shim TU (stpcpy, arc4random_buf, + // gmtime_r, strcasestr — the _WIN32 block in scr_runtime.h declares + // them) and link advapi32 (the CSPRNG RtlGenRandom/SystemFunction036, + // GetUserNameA), iphlpapi (GetAdaptersAddresses behind + // os.networkInterfaces), and ws2_32 (inet_ntop there; the socket + // units ride the same import). Never present on the default path, so + // the historical line cannot change. + unitArgs((u) => + targetPlatform(u.driver) === "win32" + ? [u.rt(join(u.rtDir, "scr_win.c")), "-ladvapi32", "-liphlpapi", "-lws2_32"] + : [], + ), + // musl deliberately has no libc-identification predefine; resolveCc's + // SCR_MUSL flag and this target-selected TU travel together. + unit((u) => isMuslTarget(u.driver), "scr_musl.c"), + // scr_glob.c (path.matchesGlob) compiles its segments through the + // regex unit and scr_idna.c (node:url's IDNA) normalizes through it, + // so both ride the same switch; section GC drops them from binaries + // that never use them. + unitArgs((u) => + u.regex + ? [ + "-I", + vendorEngineDir(), + u.rt(join(u.rtDir, "scr_regex.c")), + u.rt(join(u.rtDir, "scr_glob.c")), + u.rt(join(u.rtDir, "scr_idna.c")), + ...u.lreObjects, + ] + : [], + ), + unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"), + unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"), + unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"), + unit((u) => u.opts.dc, "scr_dc.c"), + unit((u) => u.opts.dynTier, "scr_js_value.c"), + unit((u) => u.opts.eventTarget || u.nativeFetch, "scr_event_target.c"), + unit( + (u) => + u.opts.dynAsync || + u.opts.dynInvoke || + u.opts.workers || + u.opts.dc || + u.opts.fileHandle || + u.nativeFetch, + "scr_async_dyn.c", + ), + // The performance timeline (every perf_hooks user-timing call selects + // dynAsync, whose unit carries the timeline's buffer warning). + unit((u) => u.opts.dynAsync, "scr_perf.c"), + // Blob and File (every blob.* call selects dynAsync, whose unit boxes + // their reads as dyn promises). + unit((u) => u.opts.dynAsync, "scr_blob.c"), + // The zlib UNIT (scr_zlib.c) gates on zlib.* IR use; the LINK (system + // libz on the default host-clang build, vendored objects on Zig builds) + // also serves the native fetch's gzip decoder — spread exactly once. + unitArgs((u) => + u.opts.zlib + ? isZigDriver(u.driver) + ? ["-I", vendorZlibDir(), u.rt(join(u.rtDir, "scr_zlib.c")), ...u.zlibObjects] + : [u.rt(join(u.rtDir, "scr_zlib.c"))] + : u.nativeFetch + ? isZigDriver(u.driver) + ? ["-I", vendorZlibDir(), ...u.zlibObjects] + : [] + : [], + ), + unit((u) => u.opts.events, "scr_events.c", "scr_readline.c"), + // readline's terminal mode: rl.terminal sets the regex unit it + // measures with. + unit((u) => u.opts.events && u.regex, "scr_readline_tty.c"), + unit((u) => u.opts.emitter || u.opts.workers, "scr_events_emitter.c"), + // The checked-dynamic HANDLE support unit (listener gate + runtime + // adapter closures): every referencing unit is one of the emitter or + // net families (http implies net), so handle-free binaries keep + // their exact size class. + unit((u) => u.opts.emitter || u.opts.workers || u.net, "scr_dyn_handle.c"), + unit((u) => u.opts.symbol, "scr_symbol.c"), + unit((u) => u.opts.assert && u.opts.bigint, "scr_bigint_assert.c"), + unit((u) => u.opts.qs, "scr_qs.c"), + // The legacy url unit references the regex, querystring and dyn-async + // units; the url.parse family's IR sets all three. + unit((u) => u.regex && u.opts.qs && u.opts.dynAsync, "scr_url_legacy.c"), + unit((u) => u.opts.parseArgs, "scr_util.c"), + unit((u) => u.opts.parseArgs && u.opts.symbol, "scr_util_compare.c"), + unit((u) => u.opts.parseArgs && u.opts.inspect && u.opts.dynAsync, "scr_util_style.c"), + unit( + (u) => u.opts.parseArgs && u.opts.inspect && u.opts.dynAsync && u.opts.dynInvoke && u.regex, + "scr_util_callable.c", + ), + unit((u) => u.opts.stream, "scr_stream.c", "scr_fs_stream.c"), + // The zlib stream classes reference both the zlib engine and the + // stream unit. + unit((u) => u.opts.zlib && u.opts.stream, "scr_zlib_stream.c"), + unit((u) => u.opts.stream && u.opts.dynAsync, "scr_stream_ops.c"), + // FileHandle streams reference both the stream unit and FileHandle. + unit((u) => u.opts.stream && u.opts.fileHandle, "scr_fs_stream_fh.c"), + // The readiness-poller backends (scr_platform.h): kqueue on macOS/BSD, + // epoll on Linux, WSAPoll on Windows — each TU is empty off its + // platform, so all three link whenever a poller-using unit does and + // the others cost nothing (ws2_32 rides the unconditional win32 libs + // above). + unit((u) => u.net || u.opts.dgram, "scr_loop_kqueue.c", "scr_loop_epoll.c", "scr_loop_wsapoll.c"), + unit((u) => u.net, "scr_net.c"), + unit((u) => u.http, "scr_http.c"), + unit((u) => u.opts.http2, "scr_http2.c"), + unit((u) => u.opts.dgram, "scr_dgram.c"), + unit((u) => u.opts.watch, "scr_watch.c"), + unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"), + unit((u) => u.opts.nodeTest, "scr_test.c"), + unit((u) => u.opts.webcrypto, "scr_webcrypto.c"), + // The CA-store unit rides its own gate OR the tls one: scr_tls.c + // references its default-set override unconditionally. + unit((u) => u.tlsCa, "scr_tls_ca.c"), + unitArgs((u) => + u.tlsArchive + ? [ + "-I", + join(vendorTlsDir(), "include"), + ...(u.tls ? [u.rt(join(u.rtDir, "scr_tls.c"))] : []), + ...(u.cipher ? [u.rt(join(u.rtDir, "scr_cipher.c"))] : []), + u.tlsArchive, + // mbedTLS's win32 entropy poll is BCryptGenRandom (bcrypt.h). + // The unconditional win32 libs above do not carry it. + // Never present on the default path, so the historical TLS + // link line cannot change. + ...(targetPlatform(u.driver) === "win32" ? ["-lbcrypt"] : []), + ] + : [], + ), + // scr_tls_ca.c enumerates and PEM-encodes Windows system-store entries. + // This is independent of the mbedTLS archive: getCACertificates-only + // programs need crypt32 too, while TLS programs imply the CA unit. + unitArgs((u) => (u.tlsCa && targetPlatform(u.driver) === "win32" ? ["-lcrypt32"] : [])), + // Fetch uses the native network, TLS, and HTTP runtime. + unit((u) => u.nativeFetch, "scr_fetch.c"), +]; + +/** The arguments of the optional runtime inputs, in link order. */ +function runtimeUnitArgs(u: RuntimeUnitContext): string[] { + return RUNTIME_UNITS.flatMap((entry) => + entry.args + ? [...entry.args(u)] + : entry.when(u) + ? entry.sources.map((source) => u.rt(join(u.rtDir, source))) + : [], + ); +} + +/** The runtime's optional compile-time definitions, in command-line order. + * Both the program build and the runtime-object flags use this list. */ +const RUNTIME_DEFINES: readonly { + define: string; + when: (opts: CcOptions) => boolean | undefined; +}[] = [ + { define: "SCR_TEXT_DECODER_LEGACY", when: (opts) => opts.textDecoderLegacy }, + { define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace }, + { define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended }, + { define: "SCR_YIN", when: (opts) => opts.youngNursery }, +]; + +function runtimeDefineArgs(opts: CcOptions): string[] { + return RUNTIME_DEFINES.filter((d) => d.when(opts)).map((d) => `-D${d.define}`); +} + export async function compileCInternal( opts: CcOptions, cacheWarmOnly: boolean, @@ -444,10 +656,7 @@ export async function compileCInternal( ? ["-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"] : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, - ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), - ...(opts.warningReplace ? ["-DSCR_WARNING_REPLACE"] : []), - ...(opts.stdioExtended ? ["-DSCR_STDIO_EXTENDED"] : []), - ...(opts.youngNursery ? ["-DSCR_YIN"] : []), + ...runtimeDefineArgs(opts), "-fno-math-errno", // The runtime object model uses type-punned C: a hierarchy // upcast is a raw pointer cast, so one object's header (rc, vt) and @@ -465,146 +674,22 @@ export async function compileCInternal( "-I", rtDir, ...runtimeSources.map((f) => rt(join(rtDir, f))), - ...(opts.workers - ? ["scr_worker.c", "scr_worker_events.c", "scr_mailbox.c", "scr_message.c"].map((f) => - rt(join(rtDir, f)), - ) - : []), - ...(opts.copying ? [rt(join(rtDir, "scr_copying.c"))] : []), - ...(opts.fileHandle ? [rt(join(rtDir, "scr_file_handle.c"))] : []), - // win32 targets compile the libc-shim TU (stpcpy, arc4random_buf, - // gmtime_r, strcasestr — the _WIN32 block in scr_runtime.h declares - // them) and link advapi32 (the CSPRNG RtlGenRandom/SystemFunction036, - // GetUserNameA), iphlpapi (GetAdaptersAddresses behind - // os.networkInterfaces), and ws2_32 (inet_ntop there; the socket - // units ride the same import). Never present on the default path, so - // the historical line cannot change. - ...(targetPlatform(driver) === "win32" - ? [rt(join(rtDir, "scr_win.c")), "-ladvapi32", "-liphlpapi", "-lws2_32"] - : []), - // musl deliberately has no libc-identification predefine; resolveCc's - // SCR_MUSL flag and this target-selected TU travel together. - ...(isMuslTarget(driver) ? [rt(join(rtDir, "scr_musl.c"))] : []), - // scr_glob.c (path.matchesGlob) compiles its segments through the - // regex unit and scr_idna.c (node:url's IDNA) normalizes through it, - // so both ride the same switch; section GC drops them from binaries - // that never use them. - ...(regex - ? [ - "-I", - vendorEngineDir(), - rt(join(rtDir, "scr_regex.c")), - rt(join(rtDir, "scr_glob.c")), - rt(join(rtDir, "scr_idna.c")), - ...lreObjects, - ] - : []), - ...(opts.assert || regex || opts.symbol ? [rt(join(rtDir, "scr_assert.c"))] : []), - ...(opts.inspect - ? [rt(join(rtDir, "scr_inspect.c")), rt(join(rtDir, "scr_console_native.c"))] - : []), - ...(opts.dynInvoke || opts.workers || nativeFetch ? [rt(join(rtDir, "scr_dyn_invoke.c"))] : []), - ...(opts.dc ? [rt(join(rtDir, "scr_dc.c"))] : []), - ...(opts.dynTier === true ? [rt(join(rtDir, "scr_js_value.c"))] : []), - ...(opts.eventTarget || nativeFetch ? [rt(join(rtDir, "scr_event_target.c"))] : []), - ...(opts.dynAsync || opts.dynInvoke || opts.workers || opts.dc || opts.fileHandle || nativeFetch - ? [rt(join(rtDir, "scr_async_dyn.c"))] - : []), - // The performance timeline (every perf_hooks user-timing call selects - // dynAsync, whose unit carries the timeline's buffer warning). - ...(opts.dynAsync ? [rt(join(rtDir, "scr_perf.c"))] : []), - // Blob and File (every blob.* call selects dynAsync, whose unit boxes - // their reads as dyn promises). - ...(opts.dynAsync ? [rt(join(rtDir, "scr_blob.c"))] : []), - // The zlib UNIT (scr_zlib.c) gates on zlib.* IR use; the LINK (system - // libz on the default host-clang build, vendored objects on Zig builds) - // also serves the native fetch's gzip decoder — spread exactly once. - ...(opts.zlib - ? isZigDriver(driver) - ? ["-I", vendorZlibDir(), rt(join(rtDir, "scr_zlib.c")), ...zlibObjects] - : [rt(join(rtDir, "scr_zlib.c"))] - : nativeFetch - ? isZigDriver(driver) - ? ["-I", vendorZlibDir(), ...zlibObjects] - : [] - : []), - - // through the native installation hooks — the emitted main calls its - // installer exactly then. - ...(opts.events ? [rt(join(rtDir, "scr_events.c")), rt(join(rtDir, "scr_readline.c"))] : []), - // readline's terminal mode: rl.terminal sets the regex unit it - // measures with. - ...(opts.events && regex ? [rt(join(rtDir, "scr_readline_tty.c"))] : []), - ...(opts.emitter || opts.workers ? [rt(join(rtDir, "scr_events_emitter.c"))] : []), - // The checked-dynamic HANDLE support unit (listener gate + runtime - // adapter closures): every referencing unit is one of the emitter or - // net families (http implies net), so handle-free binaries keep - // their exact size class. - ...(opts.emitter || opts.workers || net ? [rt(join(rtDir, "scr_dyn_handle.c"))] : []), - ...(opts.symbol ? [rt(join(rtDir, "scr_symbol.c"))] : []), - ...(opts.assert && opts.bigint ? [rt(join(rtDir, "scr_bigint_assert.c"))] : []), - ...(opts.qs ? [rt(join(rtDir, "scr_qs.c"))] : []), - // The legacy url unit references the regex, querystring and dyn-async - // units; the url.parse family's IR sets all three. - ...(regex && opts.qs && opts.dynAsync ? [rt(join(rtDir, "scr_url_legacy.c"))] : []), - ...(opts.parseArgs ? [rt(join(rtDir, "scr_util.c"))] : []), - ...(opts.parseArgs && opts.symbol ? [rt(join(rtDir, "scr_util_compare.c"))] : []), - ...(opts.parseArgs && opts.inspect && opts.dynAsync - ? [rt(join(rtDir, "scr_util_style.c"))] - : []), - ...(opts.parseArgs && opts.inspect && opts.dynAsync && opts.dynInvoke && regex - ? [rt(join(rtDir, "scr_util_callable.c"))] - : []), - ...(opts.stream ? [rt(join(rtDir, "scr_stream.c")), rt(join(rtDir, "scr_fs_stream.c"))] : []), - // The zlib stream classes reference both the zlib engine and the - // stream unit. - ...(opts.zlib && opts.stream ? [rt(join(rtDir, "scr_zlib_stream.c"))] : []), - ...(opts.stream && opts.dynAsync ? [rt(join(rtDir, "scr_stream_ops.c"))] : []), - // FileHandle streams reference both the stream unit and FileHandle. - ...(opts.stream && opts.fileHandle ? [rt(join(rtDir, "scr_fs_stream_fh.c"))] : []), - // The readiness-poller backends (scr_platform.h): kqueue on macOS/BSD, - // epoll on Linux, WSAPoll on Windows — each TU is empty off its - // platform, so all three link whenever a poller-using unit does and - // the others cost nothing (ws2_32 rides the unconditional win32 libs - // above). - ...(net || opts.dgram - ? [ - rt(join(rtDir, "scr_loop_kqueue.c")), - rt(join(rtDir, "scr_loop_epoll.c")), - rt(join(rtDir, "scr_loop_wsapoll.c")), - ] - : []), - ...(net ? [rt(join(rtDir, "scr_net.c"))] : []), - ...(http ? [rt(join(rtDir, "scr_http.c"))] : []), - ...((opts.http2 ?? false) ? [rt(join(rtDir, "scr_http2.c"))] : []), - ...(opts.dgram ? [rt(join(rtDir, "scr_dgram.c"))] : []), - ...(opts.watch ? [rt(join(rtDir, "scr_watch.c"))] : []), - ...(opts.foreignFfi ? [rt(join(rtDir, "scr_ffi_queue.c"))] : []), - ...(opts.nodeTest ? [rt(join(rtDir, "scr_test.c"))] : []), - ...(opts.webcrypto ? [rt(join(rtDir, "scr_webcrypto.c"))] : []), - // The CA-store unit rides its own gate OR the tls one: scr_tls.c - // references its default-set override unconditionally. - ...(tlsCa ? [rt(join(rtDir, "scr_tls_ca.c"))] : []), - ...(tlsArchive - ? [ - "-I", - join(vendorTlsDir(), "include"), - ...(tls ? [rt(join(rtDir, "scr_tls.c"))] : []), - ...(cipher ? [rt(join(rtDir, "scr_cipher.c"))] : []), - tlsArchive, - // mbedTLS's win32 entropy poll is BCryptGenRandom (bcrypt.h). - // The unconditional win32 libs above do not carry it. - // Never present on the default path, so the historical TLS - // link line cannot change. - ...(targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []), - ] - : []), - // scr_tls_ca.c enumerates and PEM-encodes Windows system-store entries. - // This is independent of the mbedTLS archive: getCACertificates-only - // programs need crypt32 too, while TLS programs imply the CA unit. - ...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []), - // Fetch uses the native network, TLS, and HTTP runtime. - ...(nativeFetch ? [rt(join(rtDir, "scr_fetch.c"))] : []), + ...runtimeUnitArgs({ + opts, + driver, + rt, + rtDir, + regex, + nativeFetch, + net, + http, + tls, + tlsCa, + cipher, + lreObjects, + zlibObjects, + tlsArchive, + }), // A .ll program TU (the LLVM backend) deliberately carries no target // triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET // triple exactly as it does for .c) — silence the -Woverride-module @@ -647,10 +732,7 @@ export async function compileCInternal( ? ["-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"] : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, - ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), - ...(opts.warningReplace ? ["-DSCR_WARNING_REPLACE"] : []), - ...(opts.stdioExtended ? ["-DSCR_STDIO_EXTENDED"] : []), - ...(opts.youngNursery ? ["-DSCR_YIN"] : []), + ...runtimeDefineArgs(opts), "-fno-math-errno", "-fno-strict-aliasing", // the emitted object model type-puns — see buildArgs "-Wno-deprecated-declarations", From 1b73e00eb6a92625e8e8ae60e41c7cc32fc1976a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:10:46 -0500 Subject: [PATCH 47/85] Keep one list of runtime pack sources for every matrix The darwin-arm64 runtime pack matrix repeated the common matrix's base and optional runtime source lists and its variantsFor table, so a new runtime source needed the same line in both files. The common matrix now exports BASE_RUNTIME_SOURCES, OPTIONAL_RUNTIME_SOURCES and variantsFor, and the darwin-arm64 matrix imports them. A comment on the lists notes that their order is the link order of an executable's runtime units. Both matrices evaluate to the same objects as before. --- .../runtime-pack-matrix.mjs | 147 +----------------- .../runtime-pack-matrix.mjs | 12 +- 2 files changed, 14 insertions(+), 145 deletions(-) diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 23c803879..3fdfd7ff5 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -1,5 +1,10 @@ import { withWorkerRuntimeVariants } from "../runtime-pack-common/worker-matrix.mjs"; import { withLibraryRuntimeFlavors } from "../runtime-pack-common/library-matrix.mjs"; +import { + BASE_RUNTIME_SOURCES, + OPTIONAL_RUNTIME_SOURCES, + variantsFor, +} from "../runtime-pack-common/runtime-pack-matrix.mjs"; /** * The executable runtime-pack matrix. This is the single source of truth for @@ -9,45 +14,6 @@ import { withLibraryRuntimeFlavors } from "../runtime-pack-common/library-matrix */ const any = (...features) => ({ any: features }); -const all = (...features) => ({ all: features }); - -const BASE_RUNTIME_SOURCES = [ - "scr_context.c", - "scr_shared.c", - "scr_number.c", - "scr_bigint.c", - "scr_string.c", - "scr_grapheme.c", - "scr_array.c", - "scr_bytes.c", - "scr_bytes_io.c", - "scr_fs_cp.c", - "scr_map.c", - "scr_closure.c", - "scr_ffi.c", - "scr_object.c", - "scr_union.c", - "scr_exception.c", - "scr_error.c", - "scr_console.c", - "scr_lib.c", - "scr_hash.c", - "scr_path.c", - "scr_os.c", - "scr_url.c", - "scr_url_params.c", - "scr_json.c", - "scr_node_builtin.c", - "scr_async.c", - "scr_crypto_async.c", - "scr_child.c", - "scr_cycle.c", - "scr_publish.c", - "scr_region.c", - "scr_itable.c", - "scr_slots.c", - "scr_alloc.c", -]; // ld64 dead-strips Mach-O symbol subsections without an ELF-style compile // flag. Keep this checked-in metadata alongside the matrix so pack build and @@ -57,107 +23,6 @@ export const EXECUTABLE_SECTION_ELIMINATION = { link_flags: ["-Wl,-dead_strip"], }; -const optional = [ - ["scr_copying.c", "copying"], - ["scr_file_handle.c", "fileHandle"], - ["scr_regex.c", "regex"], - ["scr_glob.c", "regex"], - ["scr_idna.c", "regex"], - ["scr_assert.c", any("assert", "regex", "symbol")], - ["scr_inspect.c", "inspect"], - ["scr_console_native.c", "inspect"], - ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch")], - ["scr_dc.c", "dc"], - // The dynamic tier's jsval values (two-tier design I3): linked only into - // programs whose IR has dynamic-tier operations. - ["scr_js_value.c", "dynTier"], - ["scr_event_target.c", any("eventTarget", "nativeFetch")], - ["scr_async_dyn.c", any("dynAsync", "dynInvoke", "dc", "fileHandle", "nativeFetch")], - // The performance timeline emits its buffer warning through scr_async_dyn.c. - ["scr_perf.c", "dynAsync"], - // Blob and File box their reads as dyn promises through scr_async_dyn.c. - ["scr_blob.c", "dynAsync"], - ["scr_zlib.c", "zlib"], - ["scr_events.c", "events"], - ["scr_readline.c", "events"], - ["scr_readline_tty.c", all("events", "regex")], - ["scr_events_emitter.c", "emitter"], - ["scr_dyn_handle.c", any("emitter", "netEffective")], - ["scr_symbol.c", "symbol"], - ["scr_bigint_assert.c", all("assert", "bigint")], - ["scr_qs.c", "qs"], - // The legacy url unit lowercases and normalizes through the regex unit - // and warns through the dyn-async one. - ["scr_url_legacy.c", all("regex", "qs", "dynAsync")], - ["scr_util.c", "parseArgs"], - // Utility objects must only reference runtime families selected with them. - ["scr_util_compare.c", all("parseArgs", "symbol")], - ["scr_util_style.c", all("parseArgs", "inspect", "dynAsync")], - ["scr_util_callable.c", all("parseArgs", "inspect", "dynAsync", "dynInvoke", "regex")], - ["scr_stream.c", "stream"], - // The zlib stream classes reference both the zlib engine and the stream unit. - ["scr_zlib_stream.c", all("zlib", "stream")], - // The Readable operators await through the dyn-async unit. - ["scr_stream_ops.c", all("stream", "dynAsync")], - // fs.ReadStream / fs.WriteStream reference only the stream unit and base units. - ["scr_fs_stream.c", "stream"], - // FileHandle streams reference both the stream unit and FileHandle. - ["scr_fs_stream_fh.c", all("stream", "fileHandle")], - ["scr_loop_kqueue.c", any("netEffective", "dgram")], - ["scr_loop_epoll.c", any("netEffective", "dgram")], - ["scr_loop_wsapoll.c", any("netEffective", "dgram")], - ["scr_net.c", "netEffective"], - ["scr_http.c", "httpEffective"], - ["scr_http2.c", "http2"], - ["scr_dgram.c", "dgram"], - ["scr_watch.c", "watch"], - ["scr_ffi_queue.c", "foreignFfi"], - ["scr_test.c", "nodeTest"], - ["scr_tls_ca.c", "tlsCaEffective"], - ["scr_tls.c", "tlsEffective"], - ["scr_cipher.c", "cipher"], - ["scr_webcrypto.c", "webcrypto"], - ["scr_fetch.c", "nativeFetch"], -]; - -function variantsFor(source) { - const variants = [{ id: "default", when: {}, defines: [] }]; - if (source === "scr_bytes.c") { - variants.push({ - id: "text-decoder-legacy", - when: { textDecoderLegacy: true }, - defines: ["SCR_TEXT_DECODER_LEGACY"], - }); - } - // Programs that assign process.emitWarning link the replacement-aware - // warning paths; every other program keeps the default object. - if (source === "scr_async_dyn.c") { - variants.push({ - id: "warning-replace", - when: { warningReplace: true }, - defines: ["SCR_WARNING_REPLACE"], - }); - } - // stdin setEncoding, string data listeners and stdio 'resize' events. - if (source === "scr_events.c") { - variants.push({ - id: "stdio-extended", - when: { stdioExtended: true }, - defines: ["SCR_STDIO_EXTENDED"], - }); - } - if (source === "scr_cycle.c") { - // The young-in nursery (SCRIPTC_YOUNG_NURSERY=1): the collector with - // nursery collections; programs built without it link the default. - variants.push({ - id: "young-nursery", - when: { youngNursery: true }, - defines: ["SCR_YIN"], - }); - } - return variants; -} - export const RUNTIME_PACK_MATRIX = withLibraryRuntimeFlavors( withWorkerRuntimeVariants({ schema: "scriptc.runtime-pack-matrix.v1", @@ -172,7 +37,7 @@ export const RUNTIME_PACK_MATRIX = withLibraryRuntimeFlavors( executable_section_elimination: EXECUTABLE_SECTION_ELIMINATION, runtime_units: [ ...BASE_RUNTIME_SOURCES.map((source) => ({ source, predicate: true })), - ...optional.map(([source, predicate]) => ({ source, predicate })), + ...OPTIONAL_RUNTIME_SOURCES.map(([source, predicate]) => ({ source, predicate })), ].map((unit) => ({ ...unit, variants: variantsFor(unit.source) })), archives: [ { id: "quickjs", predicate: false }, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index d7b4118e8..1e959eccc 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -9,7 +9,11 @@ import { withLibraryRuntimeFlavors } from "./library-matrix.mjs"; const any = (...features) => ({ any: features }); const all = (...features) => ({ all: features }); -const BASE_RUNTIME_SOURCES = [ +/* Every runtime pack matrix uses these lists, so a new runtime source is + * one line here. Their order is the link order of the units an executable + * selects: keep a new unit next to the units it references, and treat any + * reordering as a change to every executable's layout. */ +export const BASE_RUNTIME_SOURCES = [ "scr_context.c", "scr_shared.c", "scr_number.c", @@ -47,7 +51,7 @@ const BASE_RUNTIME_SOURCES = [ "scr_alloc.c", ]; -const OPTIONAL = [ +export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_copying.c", "copying"], ["scr_file_handle.c", "fileHandle"], ["scr_regex.c", "regex"], @@ -110,7 +114,7 @@ const OPTIONAL = [ ["scr_fetch.c", "nativeFetch"], ]; -function variantsFor(source) { +export function variantsFor(source) { const variants = [{ id: "default", when: {}, defines: [] }]; if (source === "scr_bytes.c") { variants.push({ @@ -175,7 +179,7 @@ export function createRuntimePackMatrix({ (source) => ({ source, predicate: true }), ), ...extraRuntimeSources.map((source) => ({ source, predicate: true })), - ...OPTIONAL.filter(([source]) => !omitOptionalSources.includes(source)).map( + ...OPTIONAL_RUNTIME_SOURCES.filter(([source]) => !omitOptionalSources.includes(source)).map( ([source, predicate]) => ({ source, predicate }), ), ].map((unit) => ({ ...unit, variants: variantsFor(unit.source) })), From d23942ecfad4bc9163618e1c885ddc14054430e2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:11:54 -0500 Subject: [PATCH 48/85] Keep thrown Maps, Sets, typed arrays and functions as the thrown objects A thrown Map, Set, typed array, Buffer, function or RegExp reached its catch binding as a type-erased reference, so inspection printed `{}`, String() gave "[object Object]", instanceof was false and typeof of a thrown function was "object". These values now take the checked-dynamic conversion that thrown records and arrays use, so the catch binding is the thrown object, as in Node. --- .../src/frontend/lowering/lower-stmts.ts | 24 +++++--- .../6342-throw-builtin-identity.ts.json | 6 ++ tests/corpus/6342-throw-builtin-identity.ts | 56 +++++++++++++++++++ .../6342-throw-builtin-identity.ts.json | 1 + 4 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6342-throw-builtin-identity.ts.json create mode 100644 tests/corpus/6342-throw-builtin-identity.ts create mode 100644 tests/harness/corpus-costs/6342-throw-builtin-identity.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 41c048ae9..27330ee7a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1476,6 +1476,18 @@ function lowerLabeled(lowerer: Lowerer, stmt: ts.LabeledStatement): IrStmt | IrS return { kind: "block", body, labels, loc: locOf(stmt) }; } +// Value kinds a `throw` converts to a checked-dynamic value, so the catch +// binding sees the thrown object rather than a type-erased reference. +const THROWN_AS_DYN: ReadonlySet = new Set([ + "array", + "union", + "map", + "set", + "bytes", + "func", + "regex", +]); + export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { if (ts.isClassDeclaration(stmt)) { if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous local class declarations"); @@ -1654,14 +1666,12 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt value = lowerer.coerceToExpected(value, DYN); } else if (value.type.kind === "record" && lowerer.dynConvertible(value.type)) { value = { kind: "dynFrom", value, liveRef: true, type: DYN, loc: locOf(stmt.expression) }; - } else if ( - (value.type.kind === "array" || value.type.kind === "union") && - lowerer.dynConvertible(value.type) - ) { - // Arrays, and unions over records and arrays, keep their identity - // and contents the same way: the catch binding, `Array.isArray`, + } else if (THROWN_AS_DYN.has(value.type.kind) && lowerer.dynConvertible(value.type)) { + // Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes and + // unions over them keep their identity and contents the same way: + // the catch binding, `typeof`, `instanceof`, `Array.isArray`, // inspection and typed views read the thrown object. - value = lowerer.coerceToExpected(value, DYN); + value = lowerer.coerceInto(stmt.expression, value, DYN); } if (value.type.kind === "date") { lowerer.unsupported( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6342-throw-builtin-identity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6342-throw-builtin-identity.ts.json new file mode 100644 index 000000000..09b036461 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6342-throw-builtin-identity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6342-throw-builtin-identity.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6342-throw-builtin-identity.ts b/tests/corpus/6342-throw-builtin-identity.ts new file mode 100644 index 000000000..457b94138 --- /dev/null +++ b/tests/corpus/6342-throw-builtin-identity.ts @@ -0,0 +1,56 @@ +// Thrown Maps, Sets, typed arrays, Buffers and functions reach the catch +// binding as the same objects: inspection, String(), typeof, instanceof +// and typed views see the thrown value. +function report(label: string, f: () => void): void { + try { + f(); + } catch (e) { + console.log(label, e, typeof e, e instanceof Map, e instanceof Set, ArrayBuffer.isView(e)); + } +} + +const shared = new Map([["a", 1]]); +try { + throw shared; +} catch (e) { + console.log(e === shared, String(e)); + (e as Map).set("b", 2); +} +console.log(shared); + +report("set", () => { + throw new Set([1, 2]); +}); +report("u8", () => { + throw new Uint8Array([1, 2, 3]); +}); +report("f64", () => { + throw new Float64Array([1.5, -0]); +}); +report("buffer", () => { + throw Buffer.from("hi"); +}); +report("function", () => { + throw function named(): number { + return 1; + }; +}); +const twice = (x: number): number => x * 2; +try { + throw twice; +} catch (e) { + console.log(e, typeof e, (e as (x: number) => number)(21)); +} + +function pick(k: number): Map | number[] { + return k ? new Map([["k", k]]) : [k]; +} +for (const k of [0, 3]) report(`union ${k}`, () => { + throw pick(k); +}); + +try { + throw new Set(["x"]); +} catch (e) { + console.log([...(e as Set)].join(","), (e as Set).has("x")); +} diff --git a/tests/harness/corpus-costs/6342-throw-builtin-identity.ts.json b/tests/harness/corpus-costs/6342-throw-builtin-identity.ts.json new file mode 100644 index 000000000..e2254bc18 --- /dev/null +++ b/tests/harness/corpus-costs/6342-throw-builtin-identity.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3900 } From 80355af8ea3ae20b0dba90ba230cb5fafbb25638 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:26:32 -0500 Subject: [PATCH 49/85] Give errors held as any their own stack accessor In Node every Error has an own, non-enumerable `stack` accessor, shared by all errors, that formats the stack on first access. Errors that reached checked-dynamic code read `stack` as undefined: the error encoding carried a stack only when a typed read had already formatted one. Reads on instances of Error subclasses found no class arm for it, and assigning `stack` on a subclass instance, typed or through `any`, did not change what a typed read answered. The encoding now lists `stack` first among its own properties as a non-enumerable accessor. Its getter formats the native error's stack on first access, so the heading is the name and message at that point, and its setter stores a string in the native error, which typed reads share. Any other value replaces the accessor with a data property. Objects that inherit from an error read the error's stack, and a getter called on any other receiver answers undefined. A `stack` read on a checked-dynamic value counts as a stack observation, so such programs capture frames as they do for typed reads. Dynamic reads and writes of `stack` on Error subclass instances use the native stack; non-string values are kept with the instance's own properties. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/stack-observations.test.ts | 18 +++ .../src/backend/stack-observations.ts | 5 +- .../lowering/class-dynamic-dispatch.ts | 105 +++++++++++++++ .../compiler/src/ir/builtin-signatures.ts | 1 + packages/compiler/src/ir/validate.ts | 1 + .../5916-dyn-error-stack-property.ts.json | 6 + .../6400-dyn-error-stack-property-js.js.json | 6 + packages/runtime/src/scr_error.c | 6 + packages/runtime/src/scr_error.h | 3 + packages/runtime/src/scr_json.c | 105 ++++++++++++++- packages/runtime/src/scr_message.c | 6 + tests/corpus/5916-dyn-error-stack-property.ts | 126 ++++++++++++++++++ .../6400-dyn-error-stack-property-js.js | 126 ++++++++++++++++++ .../5916-dyn-error-stack-property.ts.json | 1 + .../6400-dyn-error-stack-property-js.js.json | 1 + 16 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5916-dyn-error-stack-property.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-dyn-error-stack-property-js.js.json create mode 100644 tests/corpus/5916-dyn-error-stack-property.ts create mode 100644 tests/corpus/6400-dyn-error-stack-property-js.js create mode 100644 tests/harness/corpus-costs/5916-dyn-error-stack-property.ts.json create mode 100644 tests/harness/corpus-costs/6400-dyn-error-stack-property-js.js.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 2d91386b3..77f352571 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -827,6 +827,7 @@ export const LIB_FN_SYMS: Record = { "dyn.sameValue": "scr_dyn_same_value", "error.toString": "scr_error_to_string", "error.stack": "scr_error_stack", + "error.setStack": "scr_error_set_stack", "error.stackLimitGet": "scr_stack_limit_get", "error.stackLimitSet": "scr_stack_limit_set", "class.name": "scr_classobj_name", diff --git a/packages/compiler/src/backend/stack-observations.test.ts b/packages/compiler/src/backend/stack-observations.test.ts index a95f0140f..a09f7f58d 100644 --- a/packages/compiler/src/backend/stack-observations.test.ts +++ b/packages/compiler/src/backend/stack-observations.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest"; import { + DYN, F64, STRING, VOID, @@ -49,6 +50,23 @@ test("a reachable stack read is observed", () => { expect(observesStacks(mod([fn("%main", [call("parse")]), fn("parse", [stackRead])]))).toBe(true); }); +test("a stack read on a checked-dynamic value is observed", () => { + const value: IrExpr = { loc, kind: "varRef", localId: "d", type: DYN }; + const read = (key: string): IrStmt => ({ + loc, + kind: "exprStmt", + expr: { + loc, + kind: "dynKeyGet", + value, + key: { loc, kind: "strLit", value: key, type: STRING }, + type: DYN, + }, + }); + expect(observesStacks(mod([fn("%main", [read("stack")])]))).toBe(true); + expect(observesStacks(mod([fn("%main", [read("message")])]))).toBe(false); +}); + test("programs without stack reads are not", () => { expect(observesStacks(mod([fn("%main", [call("helper")]), fn("helper", [ret])]))).toBe(false); }); diff --git a/packages/compiler/src/backend/stack-observations.ts b/packages/compiler/src/backend/stack-observations.ts index 7debf0751..4894e927a 100644 --- a/packages/compiler/src/backend/stack-observations.ts +++ b/packages/compiler/src/backend/stack-observations.ts @@ -13,8 +13,11 @@ import { writesPublished } from "./publication.js"; /** Lib calls that read a captured stack trace. */ const STACK_READS: ReadonlySet = new Set(["error.stack"]); +/** A typed `error.stack` read, or a `stack` read on a checked-dynamic + * value, which reaches an error's own `stack` accessor. */ function readsStack(expr: IrExpr): boolean { - return expr.kind === "libCall" && STACK_READS.has(expr.fn); + if (expr.kind === "libCall") return STACK_READS.has(expr.fn); + return expr.kind === "dynKeyGet" && expr.key.kind === "strLit" && expr.key.value === "stack"; } function containsStackRead(fn: IrFunction): boolean { diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 6b515c1ff..400005ea9 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -2694,6 +2694,7 @@ export class ClassDynamicDispatch { !findGenericMethodOn(lowerer, info, dispatch.name) && dispatch.name !== "constructor" && !info.fields.has(dispatch.name) && + !(dispatch.name === "stack" && lowerer.isSubclassOf(info.def.name, "%Error")) && !findMethodOn(lowerer, info, `get:${dispatch.name}`) && !findMethodOn(lowerer, info, `set:${dispatch.name}`)) ) @@ -4498,6 +4499,110 @@ export class ClassDynamicDispatch { }, ]; } + // An Error's own `stack`: a string is the native error's stack, which + // typed reads share; any other value is kept with the instance's own + // properties, which a read checks first. A class whose chain declares + // a `stack` accessor answers through it (the accessor code below). + if ( + name === "stack" && + !info.fields.has(name) && + lowerer.isSubclassOf(info.def.name, "%Error") && + !findMethodOn(lowerer, info, "get:stack") && + !findMethodOn(lowerer, info, "set:stack") + ) { + const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + const error = lowerer.upcastTo(receiver, "%Error"); + if (write) { + const helper = classPropertiesHelper(lowerer, loc); + this.generated.add(helper); + const store: IrStmt = { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [ + { + kind: "call", + callee: helper.name, + args: [lowerer.coerceToExpected(receiver, DYN)], + type: DYN, + loc, + }, + key, + varRef("p.1", DYN, loc), + ], + type: VOID, + loc, + }, + loc, + }; + return [ + { + kind: "if", + cond: { + kind: "dynTest", + test: "string", + value: varRef("p.1", DYN, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "error.setStack", + args: [error, lowerer.coerceToExpected(varRef("p.1", DYN, loc), STRING)], + type: VOID, + loc, + }, + loc, + }, + { + kind: "if", + cond: this.readBagHas( + lowerer, + "dyn.hasKey", + this.readPropertyBag(lowerer, receiver, loc), + key, + loc, + ), + then: [store], + else_: null, + loc, + }, + ], + else_: [store], + loc, + }, + { kind: "return", value: null, loc }, + ]; + } + const bag = this.readPropertyBag(lowerer, receiver, loc); + return [ + { + kind: "return", + value: { + kind: "ternary", + cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc), + then: { + kind: "libCall", + fn: "dyn.bagGet", + args: [bag, key, lowerer.coerceToExpected(receiver, DYN)], + type: DYN, + loc, + }, + else_: lowerer.coerceToExpected( + { kind: "libCall", fn: "error.stack", args: [error], type: STRING, loc }, + DYN, + ), + type: DYN, + loc, + }, + loc, + }, + ]; + } if ((name === "message" || name === "name") && lowerer.isSubclassOf(info.def.name, "%Error")) return write ? [ diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 697a185a5..f12df2052 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3231,6 +3231,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** SameValue over checked values, including NaN, signed zero and reference identity. */ "dyn.sameValue": { argTypes: [DYN, DYN], result: BOOL }, "error.stack": { argTypes: [null], result: STRING }, + "error.setStack": { argTypes: [null, STRING], result: VOID }, "error.stackLimitGet": { argTypes: [], result: F64 }, "error.stackLimitSet": { argTypes: [F64], result: VOID }, // Receiver (any error-hierarchy object) and the program-dependent diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 4a961dfca..50ba236b8 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4109,6 +4109,7 @@ function validateFunction( if (checkLibEmitterCall(e, sig.result)) return; if ( e.fn === "error.stack" || + e.fn === "error.setStack" || e.fn === "error.cause" || e.fn === "error.hasCause" || e.fn === "error.setCause" || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5916-dyn-error-stack-property.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5916-dyn-error-stack-property.ts.json new file mode 100644 index 000000000..fa5b5460f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5916-dyn-error-stack-property.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5916-dyn-error-stack-property.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-dyn-error-stack-property-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-dyn-error-stack-property-js.js.json new file mode 100644 index 000000000..7bea48883 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6400-dyn-error-stack-property-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6400-dyn-error-stack-property-js.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 6f616c35a..4f514681b 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -354,6 +354,12 @@ ScrStr *scr_error_stack(ScrError *e) { return scr_str_retain(e->stack); } +void scr_error_set_stack(ScrError *e, ScrStr *stack) { + ScrStr *replacement = scr_str_retain(stack); + scr_str_release(e->stack); + e->stack = replacement; +} + bool scr_error_is(const void *obj) { const ScrVt *vt = ((const ScrError *)obj)->vt; return scr_error_vts[SCR_ERR_ERROR].pre <= vt->pre && diff --git a/packages/runtime/src/scr_error.h b/packages/runtime/src/scr_error.h index ce60dac23..453ebf3e1 100644 --- a/packages/runtime/src/scr_error.h +++ b/packages/runtime/src/scr_error.h @@ -252,6 +252,9 @@ void scr_error_install_message_reader(ScrStr *(*reader)(ScrError *)); void scr_error_install_name_reader(ScrStr *(*reader)(ScrError *)); ScrStr *scr_error_default_name(const ScrError *e); ScrStr *scr_error_stack(ScrError *e); +/* Replaces the error's stack string, as an assignment to `stack` does. + * Borrowed operands. */ +void scr_error_set_stack(ScrError *e, ScrStr *stack); ScrError *scr_error_retain(ScrError *e); void scr_error_release(ScrError *e); /* dispatches through e->vt */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 83b56bea4..aa331f617 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -6719,10 +6719,109 @@ static void scr_errdyn_teardown(void) { free(cache); } +/* An error's own `stack`: a non-enumerable accessor shared by every error, + * as V8 defines it. The getter formats the stack on first access, so its + * heading is the name and message at that point, and later reads answer + * the same string. Both functions find the error through the receiver or + * its prototype chain; on any other receiver the getter answers undefined + * and the setter changes nothing but the receiver's own accessor. */ +static SCR_TL ScrDyn *scr_error_stack_accessors[2]; +static SCR_TL bool scr_error_stack_accessors_registered; + +static void scr_error_stack_accessors_cleanup(void) { + for (size_t i = 0; i < 2; i++) { + scr_dyn_release(scr_error_stack_accessors[i]); + scr_error_stack_accessors[i] = NULL; + } + scr_error_stack_accessors_registered = false; +} + +/* The native error behind an accessor receiver. +1 or NULL. */ +static ScrError *scr_error_stack_holder(const ScrDyn *receiver) { + for (const ScrDyn *current = receiver; current; current = scr_dyn_proto_next(current)) { + ScrError *error = scr_errdyn_err_of(current); + if (error) return error; + if (current->kind != SCR_DYN_OBJ) return NULL; + } + return NULL; +} + +static ScrDyn *scr_error_stack_get_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; (void)args; (void)argc; + ScrDyn *self = scr_dyn_this_get(); + ScrError *error = scr_error_stack_holder(self); + scr_dyn_release(self); + if (!error) return scr_dyn_retain(scr_dyn_undefined()); + ScrStr *stack = scr_error_stack(error); + scr_error_release(error); + ScrDyn *out = scr_dyn_new_str(stack); + scr_str_release(stack); + return out; +} + +static ScrDyn *scr_error_stack_set_call(ScrClosure *closure, ScrDyn *const *args, size_t argc); + +/* Whether an own property is an error's `stack` accessor. */ +static bool scr_dyn_entry_is_error_stack(const ScrDynEntry *entry) { + return entry->accessor && entry->setter && entry->setter->kind == SCR_DYN_FUNC && + entry->setter->v.fn.thunk == scr_error_stack_set_call; +} + +static ScrDyn *scr_error_stack_set_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = scr_dyn_this_get(); + ScrError *error = scr_error_stack_holder(self); + ScrDyn *value = argc > 0 ? args[0] : scr_dyn_undefined(); + if (error && value->kind == SCR_DYN_STR) { + scr_error_set_stack(error, value->v.str); + } else if (self->kind == SCR_DYN_OBJ) { + /* The native slot holds strings only, and a typed reference's + * snapshot names no error: the receiver's own accessor becomes a data + * property holding the value, which a snapshot's commit stores. */ + ScrStr *key = scr_str_new("stack", 5); + ScrDynEntry *entry = scr_dyn_entry(self, key); + scr_str_release(key); + if (entry && scr_dyn_entry_is_error_stack(entry)) { + ScrDyn *old_value = entry->value, *old_getter = entry->getter, *old_setter = entry->setter; + entry->value = scr_dyn_retain(value); + entry->getter = entry->setter = NULL; + entry->accessor = false; + entry->writable = true; + scr_dyn_release(old_value); + scr_dyn_release(old_getter); + scr_dyn_release(old_setter); + } + } + scr_error_release(error); + scr_dyn_release(self); + return scr_dyn_retain(scr_dyn_undefined()); +} + +static void scr_error_dyn_add_stack(ScrDyn *d) { + if (!scr_error_stack_accessors_registered) { + scr_error_stack_accessors_registered = true; + scr_atexit(scr_error_stack_accessors_cleanup); + } + if (!scr_error_stack_accessors[0]) { + scr_error_stack_accessors[0] = + scr_dyn_new_func(scr_closure_new(NULL, 0), scr_error_stack_get_call, 0, "native:error.stack", NULL); + scr_error_stack_accessors[1] = + scr_dyn_new_func(scr_closure_new(NULL, 0), scr_error_stack_set_call, 1, "native:error.stack", NULL); + } + scr_dyn_obj_set(d, "stack", 5, scr_dyn_retain(scr_dyn_undefined())); + ScrDynEntry *entry = &d->v.obj.entries[d->v.obj.len - 1]; + entry->accessor = true; + entry->getter = scr_dyn_retain(scr_error_stack_accessors[0]); + entry->setter = scr_dyn_retain(scr_error_stack_accessors[1]); + entry->enumerable = false; +} + ScrDyn *scr_error_dyn_fields(const ScrError *e) { ScrDyn *d = scr_dyn_new_obj(); scr_dyn_obj_set(d, "%error", 6, scr_dyn_new_bool(true)); /* the checked-dynamic tree's error marker */ d->v.obj.entries[d->v.obj.len - 1].enumerable = false; + /* Node lists `stack` first among an error's own properties. */ + scr_error_dyn_add_stack(d); if (e->name_present) { scr_dyn_obj_set(d, "name", 4, scr_dyn_new_str(e->name)); d->v.obj.entries[d->v.obj.len - 1].enumerable = e->name_enumerable; @@ -6744,11 +6843,6 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e) { scr_dyn_obj_set(d, "message", 7, scr_dyn_new_str(e->message)); d->v.obj.entries[d->v.obj.len - 1].enumerable = e->message_enumerable; } - if (e->stack) { - /* Node's own `stack` is non-enumerable. */ - scr_dyn_obj_set(d, "stack", 5, scr_dyn_new_str(e->stack)); - d->v.obj.entries[d->v.obj.len - 1].enumerable = false; - } if (e->system_message) { /* SystemError: code, the read-only `info` context ({ message, path, * syscall, errno, code }), then errno, syscall, path and dest. */ @@ -10860,6 +10954,7 @@ static ScrDyn *scr_sc_clone(const ScrDyn *v, const ScrScParent *up, ScrDynRegion ScrDyn *out = scr_dyn_new_obj(); for (size_t i = 0; i < v->v.obj.len; i++) { const ScrDynEntry *e = &v->v.obj.entries[i]; + if (scr_dyn_entry_is_error_stack(e)) continue; ScrDyn *c = scr_sc_clone(e->value, &self, region); if (c == NULL) { scr_dyn_release(out); diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 7c969a2e3..e77e3b9c2 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -801,6 +801,12 @@ ScrDyn *scr_message_decode(const ScrMessage *message) { } if (!values[i]->v.arr.properties) values[i]->v.arr.properties = scr_dyn_new_obj(); scr_dyn_obj_set(values[i]->v.arr.properties, property->key, property->length, value); + } else if (node->kind == SCR_MESSAGE_ERROR && property->length == 5 && !memcmp(property->key, "stack", 5)) { + /* The new error's own `stack` accessor stores the value. */ + ScrStr *key = scr_str_new(property->key, property->length); + scr_dyn_key_set(values[i], key, value); + scr_str_release(key); + scr_dyn_release(value); } else scr_dyn_obj_set(values[i], property->key, property->length, value); } if (node->kind == SCR_MESSAGE_ERROR) { diff --git a/tests/corpus/5916-dyn-error-stack-property.ts b/tests/corpus/5916-dyn-error-stack-property.ts new file mode 100644 index 000000000..6198e839c --- /dev/null +++ b/tests/corpus/5916-dyn-error-stack-property.ts @@ -0,0 +1,126 @@ +// Errors held as `any` or `unknown` have an own `stack` like Node's: a +// non-enumerable accessor that every error shares. The first read formats +// "Name: message" from the error's name and message at that point, and +// later reads answer the same string. Assigning a value replaces it, +// `delete` removes it, Object.keys, spread, Object.assign and +// JSON.stringify leave it out, `in` and Object.hasOwn find it, objects +// that inherit from an error read the error's stack. Typed and dynamic +// reads answer the same string, and a stack read through `any` captures +// frames. Only the first line is printed: frame lines differ between +// runtimes. + +class ParseError extends Error { + constructor(message: string) { + super(message); + this.name = "ParseError"; + } +} + +class PlainSubclass extends Error {} + +function dyn(value: unknown): any { + return value; +} + +function firstLine(value: unknown): string { + return typeof value === "string" ? JSON.stringify(value.split("\n")[0]) : `(${typeof value} ${String(value)})`; +} + +function describe(label: string, error: any): void { + console.log( + label, + "|", + typeof error.stack, + firstLine(error.stack), + "|", + Object.keys(error).join(","), + JSON.stringify(error), + "|", + "stack" in error, + Object.hasOwn(error, "stack"), + error.propertyIsEnumerable("stack"), + ); +} + +try { + throw new RangeError("thrown"); +} catch (error) { + describe("caught", error); +} + +const unknownError: unknown = new TypeError("bad thing"); +describe("unknown", unknownError); +describe("plain", dyn(new Error("plain"))); +describe("no message", dyn(new SyntaxError())); +describe("subclass", dyn(new ParseError("line 3"))); +describe("plain subclass", dyn(new PlainSubclass("inherited name"))); + +const coded = dyn(new Error("with code")); +coded.code = "E_CODE"; +describe("own property", coded); + +const descriptor = Object.getOwnPropertyDescriptor(dyn(new Error("a")), "stack")!; +const other = Object.getOwnPropertyDescriptor(dyn(new TypeError("b")), "stack")!; +console.log("descriptor", Object.keys(descriptor).join(","), descriptor.enumerable, descriptor.configurable); +console.log("shared accessors", typeof descriptor.get, descriptor.get === other.get, descriptor.set === other.set); +console.log("foreign receiver", descriptor.get!.call({}), firstLine(descriptor.get!.call(dyn(new TypeError("b"))))); +const subclassDescriptor = Object.getOwnPropertyDescriptor(dyn(new PlainSubclass("s")), "stack")!; +console.log("subclass descriptor", Object.keys(subclassDescriptor).join(","), subclassDescriptor.get === descriptor.get); + +const renamed = dyn(new Error("before")); +renamed.message = "after"; +renamed.name = "Renamed"; +console.log("first read", firstLine(renamed.stack)); +renamed.message = "later"; +console.log("later read", firstLine(renamed.stack)); +const subclassRenamed = dyn(new PlainSubclass("before")); +subclassRenamed.message = "after"; +console.log("subclass first read", firstLine(subclassRenamed.stack)); + +const replaced = dyn(new Error("replaced")); +replaced.stack = "custom stack\n at nowhere"; +describe("assigned", replaced); +console.log("assigned descriptor", Object.keys(Object.getOwnPropertyDescriptor(replaced, "stack")!).join(",")); +const subclassReplaced = dyn(new ParseError("replaced")); +subclassReplaced.stack = "subclass stack"; +console.log("subclass assigned", subclassReplaced.stack); +const typedReplaced = new ParseError("typed write"); +typedReplaced.stack = "typed subclass stack"; +console.log("typed subclass assigned", typedReplaced.stack, dyn(typedReplaced).stack); +const subclassNumbered = dyn(new PlainSubclass("numbered")); +subclassNumbered.stack = 7; +console.log("subclass number", subclassNumbered.stack, typeof subclassNumbered.stack); +subclassNumbered.stack = "string again"; +console.log("subclass string again", subclassNumbered.stack); +const numbered = dyn(new Error("numbered")); +numbered.stack = 5; +console.log("number", numbered.stack, typeof numbered.stack); +const removed = dyn(new Error("removed")); +delete removed.stack; +console.log("deleted", removed.stack, "stack" in removed, Object.hasOwn(removed, "stack")); + +const spreadSource = dyn(new Error("spread")); +console.log("spread", Object.keys({ ...spreadSource }).length, Object.keys(Object.assign({}, spreadSource)).length); +console.log("inherited", firstLine(Object.create(dyn(new RangeError("prototype"))).stack)); + +const typed = new Error("typed"); +const view = dyn(typed); +console.log("typed and dynamic", view.stack === typed.stack, firstLine(typed.stack)); +const typedSubclass = new ParseError("typed subclass"); +console.log("typed subclass", firstLine(dyn(typedSubclass).stack), dyn(typedSubclass).stack === typedSubclass.stack); +const assignedThroughView = new Error("typed"); +dyn(assignedThroughView).stack = "through the view"; +console.log("assigned through view", assignedThroughView.stack); + +function inner(): any { + return dyn(new Error("deep")); +} +function outer(): any { + return inner(); +} +const deep: string = outer().stack; +console.log("frames", deep.split("\n").length > 1, deep.includes("at inner"), deep.includes("at outer")); +function makeSubclass(): any { + return dyn(new PlainSubclass("deep")); +} +console.log("subclass frames", (makeSubclass().stack as string).includes("at makeSubclass")); diff --git a/tests/corpus/6400-dyn-error-stack-property-js.js b/tests/corpus/6400-dyn-error-stack-property-js.js new file mode 100644 index 000000000..e46db41eb --- /dev/null +++ b/tests/corpus/6400-dyn-error-stack-property-js.js @@ -0,0 +1,126 @@ +// In JavaScript sources, errors have an own `stack` like Node's: a +// non-enumerable accessor that every error shares. The first read formats +// "Name: message" from the error's name and message at that point, and +// later reads answer the same string. Assigning a value replaces it, +// `delete` removes it, Object.keys, spread, Object.assign and +// JSON.stringify leave it out, `in` and Object.hasOwn find it, objects +// that inherit from an error read the error's stack. Typed and dynamic +// reads answer the same string, and a stack read through `any` captures +// frames. Only the first line is printed: frame lines differ between +// runtimes. + +class ParseError extends Error { + constructor(message) { + super(message); + this.name = "ParseError"; + } +} + +class PlainSubclass extends Error {} + +function dyn(value) { + return value; +} + +function firstLine(value) { + return typeof value === "string" ? JSON.stringify(value.split("\n")[0]) : `(${typeof value} ${String(value)})`; +} + +function describe(label, error) { + console.log( + label, + "|", + typeof error.stack, + firstLine(error.stack), + "|", + Object.keys(error).join(","), + JSON.stringify(error), + "|", + "stack" in error, + Object.hasOwn(error, "stack"), + error.propertyIsEnumerable("stack"), + ); +} + +try { + throw new RangeError("thrown"); +} catch (error) { + describe("caught", error); +} + +const unknownError = new TypeError("bad thing"); +describe("unknown", unknownError); +describe("plain", dyn(new Error("plain"))); +describe("no message", dyn(new SyntaxError())); +describe("subclass", dyn(new ParseError("line 3"))); +describe("plain subclass", dyn(new PlainSubclass("inherited name"))); + +const coded = dyn(new Error("with code")); +coded.code = "E_CODE"; +describe("own property", coded); + +const descriptor = dyn(Object.getOwnPropertyDescriptor(dyn(new Error("a")), "stack")); +const other = dyn(Object.getOwnPropertyDescriptor(dyn(new TypeError("b")), "stack")); +console.log("descriptor", Object.keys(descriptor).join(","), descriptor.enumerable, descriptor.configurable); +console.log("shared accessors", typeof descriptor.get, descriptor.get === other.get, descriptor.set === other.set); +console.log("foreign receiver", descriptor.get.call({}), firstLine(descriptor.get.call(dyn(new TypeError("b"))))); +const subclassDescriptor = dyn(Object.getOwnPropertyDescriptor(dyn(new PlainSubclass("s")), "stack")); +console.log("subclass descriptor", Object.keys(subclassDescriptor).join(","), subclassDescriptor.get === descriptor.get); + +const renamed = dyn(new Error("before")); +renamed.message = "after"; +renamed.name = "Renamed"; +console.log("first read", firstLine(renamed.stack)); +renamed.message = "later"; +console.log("later read", firstLine(renamed.stack)); +const subclassRenamed = dyn(new PlainSubclass("before")); +subclassRenamed.message = "after"; +console.log("subclass first read", firstLine(subclassRenamed.stack)); + +const replaced = dyn(new Error("replaced")); +replaced.stack = "custom stack\n at nowhere"; +describe("assigned", replaced); +console.log("assigned descriptor", Object.keys(Object.getOwnPropertyDescriptor(replaced, "stack")).join(",")); +const subclassReplaced = dyn(new ParseError("replaced")); +subclassReplaced.stack = "subclass stack"; +console.log("subclass assigned", subclassReplaced.stack); +const typedReplaced = new ParseError("typed write"); +typedReplaced.stack = "typed subclass stack"; +console.log("typed subclass assigned", typedReplaced.stack, dyn(typedReplaced).stack); +const subclassNumbered = dyn(new PlainSubclass("numbered")); +subclassNumbered.stack = 7; +console.log("subclass number", subclassNumbered.stack, typeof subclassNumbered.stack); +subclassNumbered.stack = "string again"; +console.log("subclass string again", subclassNumbered.stack); +const numbered = dyn(new Error("numbered")); +numbered.stack = 5; +console.log("number", numbered.stack, typeof numbered.stack); +const removed = dyn(new Error("removed")); +delete removed.stack; +console.log("deleted", removed.stack, "stack" in removed, Object.hasOwn(removed, "stack")); + +const spreadSource = dyn(new Error("spread")); +console.log("spread", Object.keys({ ...spreadSource }).length, Object.keys(Object.assign({}, spreadSource)).length); +console.log("inherited", firstLine(Object.create(dyn(new RangeError("prototype"))).stack)); + +const typed = new Error("typed"); +const view = dyn(typed); +console.log("typed and dynamic", view.stack === typed.stack, firstLine(typed.stack)); +const typedSubclass = new ParseError("typed subclass"); +console.log("typed subclass", firstLine(dyn(typedSubclass).stack), dyn(typedSubclass).stack === typedSubclass.stack); +const assignedThroughView = new Error("typed"); +dyn(assignedThroughView).stack = "through the view"; +console.log("assigned through view", assignedThroughView.stack); + +function inner() { + return dyn(new Error("deep")); +} +function outer() { + return inner(); +} +const deep = outer().stack; +console.log("frames", deep.split("\n").length > 1, deep.includes("at inner"), deep.includes("at outer")); +function makeSubclass() { + return dyn(new PlainSubclass("deep")); +} +console.log("subclass frames", makeSubclass().stack.includes("at makeSubclass")); diff --git a/tests/harness/corpus-costs/5916-dyn-error-stack-property.ts.json b/tests/harness/corpus-costs/5916-dyn-error-stack-property.ts.json new file mode 100644 index 000000000..3ed0acb36 --- /dev/null +++ b/tests/harness/corpus-costs/5916-dyn-error-stack-property.ts.json @@ -0,0 +1 @@ +{ "plain": 3232, "san": 4298 } diff --git a/tests/harness/corpus-costs/6400-dyn-error-stack-property-js.js.json b/tests/harness/corpus-costs/6400-dyn-error-stack-property-js.js.json new file mode 100644 index 000000000..c51a3475f --- /dev/null +++ b/tests/harness/corpus-costs/6400-dyn-error-stack-property-js.js.json @@ -0,0 +1 @@ +{ "plain": 3172, "san": 4044 } From 762718a254b9ae4063cc3a167a70dcf906b2e2ab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:26:47 -0700 Subject: [PATCH 50/85] Compile TLSSocket.isSessionReused, keyed short-circuit assignment and Object.assign into class instances - TLSSocket.isSessionReused() is declared in the fallback types and lowers to false: the TLS client never offers a cached session (no session resumption is a documented divergence), so no handshake resumes one. - `o[k] ??= v`, `||=` and `&&=` through a pure index-signature record with a string key use the keyed read and write `o[k]` and `o[k] = v` lower to. - Object.assign(instance, record) writes the record's fields into the class instance's declared fields (same or width-compatible types; optional source fields only when present) and answers the instance. - Object.assign({}, instance) whose result the checker types as the class is refused at compile time: the copy is a plain object, so the checked cast into the class threw at run time. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 2 + .../expressions/nullish-assignment.ts | 39 +++++ .../src/frontend/lowering/lower-calls.ts | 134 ++++++++++++++++++ .../src/frontend/lowering/lower-server.ts | 14 ++ .../corpus/6416-tls-session-reused.ts.json | 6 + .../6417-object-assign-class-target.ts.json | 6 + .../6418-nullish-assign-index-record.ts.json | 6 + .../object-assign-class-copy.ts.json | 6 + .../node-types/tls-session-timings.ts.json | 6 + tests/corpus/6416-tls-session-reused.ts | 33 +++++ .../corpus/6417-object-assign-class-target.ts | 36 +++++ .../6418-nullish-assign-index-record.ts | 29 ++++ tests/diagnostics/object-assign-class-copy.ts | 11 ++ .../node-types/tls-session-timings.ts | 31 ++++ .../object-assign-class-copy.ts.txt | 6 + tests/harness/project-config.test.ts | 21 +++ 16 files changed, 386 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6416-tls-session-reused.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6417-object-assign-class-target.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6418-nullish-assign-index-record.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-assign-class-copy.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-session-timings.ts.json create mode 100644 tests/corpus/6416-tls-session-reused.ts create mode 100644 tests/corpus/6417-object-assign-class-target.ts create mode 100644 tests/corpus/6418-nullish-assign-index-record.ts create mode 100644 tests/diagnostics/object-assign-class-copy.ts create mode 100644 tests/fixtures/node-types/tls-session-timings.ts create mode 100644 tests/harness/__snapshots__/object-assign-class-copy.ts.txt diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index a8f1f8a8b..628e14f25 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -5238,6 +5238,8 @@ declare module "tls" { getProtocol(): string | null; /* OpenSSL's suite name, the IANA name and the suite's version */ getCipher(): CipherNameAndProtocol; + /* whether the handshake resumed a cached session */ + isSessionReused(): boolean; } export interface CipherNameAndProtocol { name: string; diff --git a/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts b/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts index 5ef3991da..4366b8d6b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts +++ b/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts @@ -214,6 +214,37 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp !nullish && !and && element.kind === "f64" ? arrayNumberRead(receiver, key, elementLoc) : arrayValueRead(lowerer, receiver, key, element, elementLoc); + } else if ( + receiver.type.kind === "record" && + key.type.kind === "string" && + pureIndexShapeOf(lowerer, receiver.type.shapeId) !== null + ) { + // `timings[phase] ??= v` over a pure index-signature record + // (`Record`): the keyed read answers the value or + // undefined for a missing key, and the write inserts or replaces + // the key, exactly the two halves `o[k]` and `o[k] = v` lower to. + const shapeId = receiver.type.shapeId; + const indexValue = pureIndexShapeOf(lowerer, shapeId)!; + key = save(key, "%shortCircuitKey"); + const readType = + indexValue.kind === "dyn" ? indexValue : lowerer.withUndefinedArmOf(indexValue); + if (!readType) { + lowerer.unsupported( + "SC1090", + target, + "short-circuit assignment through this indexed receiver", + ); + } + storage = indexValue; + read = { kind: "recordKeyGet", obj: receiver, shapeId, key, type: readType, loc }; + write = (value) => ({ + kind: "recordKeySet", + obj: receiver, + shapeId, + key, + value: lowerer.coerceToExpected(value, indexValue), + loc, + }); } else { lowerer.unsupported( "SC1090", @@ -265,3 +296,11 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp } return finish({ kind: "nullish", left: read, right, type: read.type, loc }); } + +/** The value type of a pure index-signature record shape (no declared + * fields, not a tuple), or null. */ +function pureIndexShapeOf(lowerer: Lowerer, shapeId: string): IrType | null { + const shape = lowerer.shapes.get(shapeId); + if (!shape || shape.tuple || shape.fields.length > 0 || !shape.indexValue) return null; + return shape.indexValue; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 5a4838782..699f04ec8 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6769,6 +6769,99 @@ function recordHasOwnHelper(lowerer: Lowerer, shapeId: string, loc: SrcLoc): str return helper; } +/** Whether `Object.assign(instance, record)` can copy every field of the + * record shape into a field the class declares with the same type: a plain + * record (no tuple, index signature or accessor slots) and a compiled + * class whose chain has no builtin or runtime-provided storage and no + * accessor for the written names. */ +function classAssignPlan(lowerer: Lowerer, className: string, srcShapeId: string): boolean { + const info = lowerer.classes.get(className); + const shape = lowerer.shapes.get(srcShapeId); + if (!info || !shape || shape.tuple || shape.indexValue || shapeHasAccessorSlots(shape)) + return false; + for (let c: ClassInfo | null = info; c; c = c.base) { + if (c.builtinError || c.builtinEmitter || c.builtinStream !== undefined || c.def.runtime) + return false; + } + return shape.fields.every((f) => { + const declared = info.fields.get(f.name); + return ( + declared !== undefined && + (typeEquals(declared, f.type) || lowerer.widthLiftPlan(f.type, declared) !== null) && + !lowerer.findMethodOn(info, f.name) && + !lowerer.findMethodOn(info, `get:${f.name}`) && + !lowerer.findMethodOn(info, `set:${f.name}`) + ); + }); +} + +/** Interned `%obj.assignClass.(o, s)` — Object.assign's per-field + * write of a record into a class instance (classAssignPlan's gate): + * optional source fields write only when present, and the instance + * returns. */ +function classAssignHelper( + lowerer: Lowerer, + className: string, + srcShapeId: string, + loc: SrcLoc, +): string | null { + if (!classAssignPlan(lowerer, className, srcShapeId)) return null; + const key = `obj.assignClass:${className}:${srcShapeId}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const helper = `%obj.assignClass.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const sShape = lowerer.shapes.get(srcShapeId)!; + const oT: IrType = { kind: "object", className }; + const sT: IrType = { kind: "record", shapeId: srcShapeId }; + const oRef: IrExpr = { kind: "varRef", localId: "o.0", type: oT, loc }; + const sRef: IrExpr = { kind: "varRef", localId: "s.0", type: sT, loc }; + const body: IrStmt[] = []; + const info = lowerer.classes.get(className)!; + for (const f of sShape.fields) { + const declared = info.fields.get(f.name)!; + const read: IrExpr = { + kind: "recordGet", + obj: sRef, + shapeId: srcShapeId, + field: f.name, + type: f.type, + loc, + }; + const lift = typeEquals(declared, f.type) ? null : lowerer.widthLiftPlan(f.type, declared); + const set: IrStmt = { + kind: "fieldSet", + obj: oRef, + className, + field: f.name, + value: lift === null ? read : lowerer.applyWidthLift(lift, read, declared, loc), + loc, + }; + const present = lowerer.recordFieldPresent(sRef, srcShapeId, f.name, loc); + body.push( + present.kind === "recordHas" + ? { kind: "if", cond: present, then: [set], else_: null, loc } + : set, + ); + } + body.push({ kind: "return", value: oRef, loc }); + lowerer.liftedFns.push({ + name: helper, + params: [ + { localId: "o.0", name: "o", type: oT }, + { localId: "s.0", name: "s", type: sT }, + ], + returnType: oT, + locals: [ + { id: "o.0", name: "o", type: oT, mutable: true }, + { id: "s.0", name: "s", type: sT, mutable: true }, + ], + body, + loc, + }); + return helper; +} + /** Interned `%obj.assign.(t, s)` — Object.assign's per-field copy * over signature-free records (every source field lands on a same-named, * same-typed target field — the caller's gate): optional source fields @@ -7410,6 +7503,28 @@ function lowerObjectStaticCall( return { kind: "libCall", fn: "dyn.assign", args: [target, source], type: DYN, loc }; } } + // A CLASS-INSTANCE target and a plain record source whose fields are + // all fields the class declares with the same types + // (`Object.assign(this, { retries: 3 })`): the per-field write into + // the instance, returning it — the instance mutates in place. + if (tProbe?.type.kind === "object" && sProbe?.type.kind === "record") { + const helper = classAssignPlan(lowerer, tProbe.type.className, sProbe.type.shapeId); + if (helper) { + const loc = locOf(call); + const target = lowerer.lowerExpr(call.arguments.nodes[0]!); + const source = lowerer.lowerExpr(call.arguments.nodes[1]!); + if (target.type.kind === "object" && source.type.kind === "record") { + const name = classAssignHelper( + lowerer, + target.type.className, + source.type.shapeId, + loc, + ); + if (name) + return { kind: "call", callee: name, args: [target, source], type: target.type, loc }; + } + } + } if (tProbe?.type.kind === "record" && sProbe?.type.kind === "record") { const tShape = lowerer.shapes.get(tProbe.type.shapeId); const sShape = lowerer.shapes.get(sProbe.type.shapeId); @@ -7500,6 +7615,25 @@ function lowerObjectStaticCall( const srcNode = spread ? argNode.expression : argNode; const rawSource = lowerer.lowerExpr(srcNode); fenceSymbolFieldCopy(lowerer, srcNode, rawSource.type, spread); + // A class instance copied into a fresh object + // (`Object.assign({}, instance)`): the result is a plain + // object holding the instance's own fields, never an + // instance, so a result the checker types as the class has + // no representation (the checked cast would throw). + if ( + rawSource.type.kind === "object" && + freshLiteralTarget && + !ts.isExpressionStatement(call.parent) + ) { + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (resultT !== null && resultT.kind === "object") { + lowerer.unsupported( + "SC1090", + srcNode, + `Object.assign copying a '${lowerer.fmt(rawSource.type)}' instance into a fresh object typed as the class (the copy is a plain object holding the instance's own fields, not an instance — build the object literal from the fields you need)`, + ); + } + } const src = lowerer.coerceToExpected(rawSource, DYN); if (src.type.kind !== "dyn") { ok = false; diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 0e1438fc9..7cdb9ef5d 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2476,6 +2476,20 @@ function lowerNetSocketMethodCall( ? value : lowerer.coerceInto(call, value, declared); } + if (name === "isSessionReused" && args.length === 0) { + // TLSSocket.isSessionReused(): the client never offers a cached + // session (no session resumption, a documented TLS divergence), so + // no handshake resumes one. A plain socket asserted to be a + // TLSSocket answers false too, where Node has no such method. + const receiver = coerceToHandle(lowerer, access.expression, NETSOCKET_T); + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: receiver, loc }], + result: boolLit(false, loc), + type: BOOL, + loc, + }; + } if (name === "resetAndDestroy") { // An RST close; a connecting socket resets once it connects. Answers // the socket, Node's chaining. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6416-tls-session-reused.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6416-tls-session-reused.ts.json new file mode 100644 index 000000000..8b84ce6d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6416-tls-session-reused.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6416-tls-session-reused.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6417-object-assign-class-target.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6417-object-assign-class-target.ts.json new file mode 100644 index 000000000..5832b91f9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6417-object-assign-class-target.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6417-object-assign-class-target.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6418-nullish-assign-index-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6418-nullish-assign-index-record.ts.json new file mode 100644 index 000000000..7a030cd19 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6418-nullish-assign-index-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6418-nullish-assign-index-record.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-assign-class-copy.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-assign-class-copy.ts.json new file mode 100644 index 000000000..085f01796 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-assign-class-copy.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/object-assign-class-copy.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-session-timings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-session-timings.ts.json new file mode 100644 index 000000000..953193649 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/tls-session-timings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/tls-session-timings.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6416-tls-session-reused.ts b/tests/corpus/6416-tls-session-reused.ts new file mode 100644 index 000000000..a30f4f1b0 --- /dev/null +++ b/tests/corpus/6416-tls-session-reused.ts @@ -0,0 +1,33 @@ +// TLSSocket.isSessionReused() on both ends of a fresh TLS connection, +// before and after the handshake: a first handshake never resumes a +// session, so every answer is false. +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const cert = readFileSync("tests/fixtures/server/certs/localhost.pem"); +const key = readFileSync("tests/fixtures/server/certs/localhost-key.pem"); +const ca = readFileSync("tests/fixtures/server/certs/ca.pem"); + +let serverLine: string | null = null; +let clientLine: string | null = null; +function both(): void { + if (serverLine === null || clientLine === null) return; + console.log(serverLine); + console.log(clientLine); +} + +const server = tls.createServer({ key, cert }, (socket) => { + serverLine = `server reused ${socket.isSessionReused()}`; + both(); + socket.end("hi"); +}); +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as { port: number }).port; + const client = tls.connect({ port, host: "127.0.0.1", servername: "localhost", ca }, () => { + clientLine = `client reused ${client.isSessionReused()}`; + both(); + }); + console.log("before handshake", client.isSessionReused()); + client.on("data", (d: Buffer) => console.log("data", d.toString())); + client.on("close", () => server.close()); +}); diff --git a/tests/corpus/6417-object-assign-class-target.ts b/tests/corpus/6417-object-assign-class-target.ts new file mode 100644 index 000000000..4c2746693 --- /dev/null +++ b/tests/corpus/6417-object-assign-class-target.ts @@ -0,0 +1,36 @@ +// Object.assign with a class instance as the target and a plain record as +// the source: the record's fields land on the instance's own fields (also +// readonly ones, which only the checker guards), optional source fields +// only when present, and the call answers the instance itself. +class Settings { + retries = 1; + readonly name: string = "default"; + verbose = false; + label?: string; + describe(): string { + return `${this.name}:${this.retries}:${this.verbose}:${this.label ?? "-"}`; + } +} + +const s = new Settings(); +const same = Object.assign(s, { retries: 3, verbose: true }); +console.log(same === s, s.describe()); + +function patch(target: Settings, update: { label?: string }): Settings { + return Object.assign(target, update); +} +console.log(patch(s, { label: "x" }).describe()); +console.log(patch(s, {}).describe()); +console.log(patch(s, { label: undefined }).describe(), Object.assign(s, { retries: 0 }).describe()); + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} +const p = new Point(1, 2); +Object.assign(p, { y: 5 }); +console.log(p.x, p.y, JSON.stringify(p), p instanceof Point); diff --git a/tests/corpus/6418-nullish-assign-index-record.ts b/tests/corpus/6418-nullish-assign-index-record.ts new file mode 100644 index 000000000..97918cd98 --- /dev/null +++ b/tests/corpus/6418-nullish-assign-index-record.ts @@ -0,0 +1,29 @@ +// Short-circuit assignment through a string-keyed index-signature record +// (`timings[phase] ??= value`): a missing key takes the right side, a +// present one keeps its value, and `||=` and `&&=` test truthiness. +class Telemetry { + private readonly timings: Record = {}; + private readonly labels: { [key: string]: string } = {}; + mark(phase: string, at: number): number { + return (this.timings[phase] ??= at); + } + label(key: string, value: string): string { + this.labels[key] ||= value; + this.labels[key] &&= this.labels[key].toUpperCase(); + return this.labels[key]; + } + dump(): string { + return JSON.stringify(this.timings) + " " + JSON.stringify(this.labels); + } +} + +const t = new Telemetry(); +console.log(t.mark("dns", 5), t.mark("tcp", 7), t.mark("dns", 9)); +console.log(t.label("a", "first"), t.label("a", "second"), t.label("", "empty")); +console.log(t.dump()); + +const counts: Record = { zero: 0 }; +const keys = ["zero", "one", "zero"]; +for (const k of keys) counts[k] ??= 10; +for (const k of keys) counts[k] ||= 20; +console.log(JSON.stringify(counts)); diff --git a/tests/diagnostics/object-assign-class-copy.ts b/tests/diagnostics/object-assign-class-copy.ts new file mode 100644 index 000000000..4899306a9 --- /dev/null +++ b/tests/diagnostics/object-assign-class-copy.ts @@ -0,0 +1,11 @@ +// Object.assign copying a class instance into a fresh object: the result +// is a plain object holding the instance's own fields, never an instance, +// so a result the checker types as the class is refused rather than +// failing its checked cast at run time. +class Config { + b = 1; + a = 2; +} +const config = new Config(); +const copy = Object.assign({}, config); +console.log(JSON.stringify(copy), Object.keys(copy).join(",")); diff --git a/tests/fixtures/node-types/tls-session-timings.ts b/tests/fixtures/node-types/tls-session-timings.ts new file mode 100644 index 000000000..7ebb53ce9 --- /dev/null +++ b/tests/fixtures/node-types/tls-session-timings.ts @@ -0,0 +1,31 @@ +// Under @types/node: a connector-style TLS client reads +// isSessionReused() through a TLSSocket assertion and keeps per-phase +// timings in a string-keyed record with `??=`. +import * as net from "node:net"; +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const cert = readFileSync("tests/fixtures/server/certs/localhost.pem"); +const key = readFileSync("tests/fixtures/server/certs/localhost-key.pem"); +const ca = readFileSync("tests/fixtures/server/certs/ca.pem"); + +const timings: Record = {}; +function mark(phase: string, value: number): void { + timings[phase] ??= value; +} + +const server = tls.createServer({ key, cert }, (socket) => socket.end("ok")); +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + const socket: net.Socket = tls.connect({ port, host: "127.0.0.1", servername: "localhost", ca }, () => { + mark("secure", 1); + mark("secure", 2); + console.log("reused", (socket as tls.TLSSocket).isSessionReused()); + }); + socket.on("data", (d: Buffer) => console.log("data", d.toString())); + socket.on("close", () => { + mark("close", 3); + console.log(JSON.stringify(timings)); + server.close(); + }); +}); diff --git a/tests/harness/__snapshots__/object-assign-class-copy.ts.txt b/tests/harness/__snapshots__/object-assign-class-copy.ts.txt new file mode 100644 index 000000000..ce25cc38f --- /dev/null +++ b/tests/harness/__snapshots__/object-assign-class-copy.ts.txt @@ -0,0 +1,6 @@ +object-assign-class-copy.ts:10:32 - error SC1090: Object.assign copying a 'Config' instance into a fresh object typed as the class (the copy is a plain object holding the instance's own fields, not an instance — build the object literal from the fields you need) is not supported yet + + 9 | const config = new Config(); + 10 | const copy = Object.assign({}, config); + | ^~~~~~ + 11 | console.log(JSON.stringify(copy), Object.keys(copy).join(",")); \ No newline at end of file diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 802fbd3e1..e979c772c 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -200,6 +200,27 @@ test("node-types: socket.address and resetAndDestroy under @types/node", async ( expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: TLSSocket.isSessionReused and keyed ??= timings under @types/node", async () => { + const outDir = outDirFor("tls-session-timings"); + const entry = join(nodeTypesDir, "tls-session-timings.ts"); + const result = await compile(entry, { + outPath: join( + outDir, + process.platform === "win32" ? "tls-session-timings.exe" : "tls-session-timings", + ), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: net.SocketAddress and net.BlockList under @types/node", async () => { const outDir = outDirFor("net-blocklist"); const entry = join(nodeTypesDir, "net-blocklist.ts"); From 60f5c852e067e7eb7c2059412b1f7ee3b547c8e4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:31:09 -0500 Subject: [PATCH 51/85] Print holes in arrays reached through unknown as Node does A holey array converted to a checked-dynamic value keeps its holes (the conversion records them in the array's presence map), but the runtime inspector printed every element: `const u: unknown = a` for `a[2] = 1` printed `[ undefined, undefined, 1 ]`, where Node prints `[ <2 empty items>, 1 ]`, and `new Array(3)` through `unknown` printed `[ undefined, undefined, undefined ]`. The inspector now prints each run of holes as one `` entry that counts toward the 100-entry limit, as Node's formatSpecialArray does and as the typed array inspector already does. --- .../corpus/6345-inspect-dyn-holes.ts.json | 6 ++++ packages/runtime/src/scr_inspect.c | 29 ++++++++++++++----- tests/corpus/6345-inspect-dyn-holes.ts | 29 +++++++++++++++++++ .../6345-inspect-dyn-holes.ts.json | 1 + 4 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6345-inspect-dyn-holes.ts.json create mode 100644 tests/corpus/6345-inspect-dyn-holes.ts create mode 100644 tests/harness/corpus-costs/6345-inspect-dyn-holes.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6345-inspect-dyn-holes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6345-inspect-dyn-holes.ts.json new file mode 100644 index 000000000..d9027a759 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6345-inspect-dyn-holes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6345-inspect-dyn-holes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 3194528d3..e38c7eb9b 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1362,20 +1362,35 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { case SCR_DYN_STR: return scr_insp_str(d->v.str); case SCR_DYN_ARR: { - if (d->v.arr.len == 0) return scr_str_new("[]", 2); + size_t len = d->v.arr.len; + if (len == 0) return scr_str_new("[]", 2); if (recurse > depth) return scr_str_new("[Array]", 7); scr_insp_begin(recurse + 1); - size_t shown = d->v.arr.len < 100 ? d->v.arr.len : 100; - for (size_t i = 0; i < shown; i++) { - ScrDyn *item = d->v.arr.items[i]; + /* Node's maxArrayLength (100) counts output entries; a run of holes + * is one `` entry (formatSpecialArray). */ + const unsigned char *presence = d->v.arr.presence; + size_t i = 0; + for (size_t shown = 0; i < len && shown < 100; shown++) { + if (presence && !presence[i]) { + size_t next = i + 1; + while (next < len && !presence[next]) next++; + char hole[48]; + int hn = snprintf(hole, sizeof hole, "<%zu empty item%s>", next - i, next - i == 1 ? "" : "s"); + ScrStr *s = scr_str_new(hole, (size_t)hn); + scr_insp_entry(s, false); + scr_str_release(s); + i = next; + continue; + } + ScrDyn *item = d->v.arr.items[i++]; ScrStr *s = scr_insp_dyn(item, recurse + 1, depth); scr_insp_entry(s, item->kind == SCR_DYN_NUM); scr_str_release(s); } - bool more = d->v.arr.len > 100; + bool more = i < len; if (more) { - ScrStr *m = scr_insp_more_items((double)(d->v.arr.len - 100)); - scr_insp_entry(m, d->v.arr.items[100]->kind == SCR_DYN_NUM); + ScrStr *m = scr_insp_more_items((double)(len - i)); + scr_insp_entry(m, d->v.arr.items[i]->kind == SCR_DYN_NUM); scr_str_release(m); } ScrStr *base = scr_str_new("", 0); diff --git a/tests/corpus/6345-inspect-dyn-holes.ts b/tests/corpus/6345-inspect-dyn-holes.ts new file mode 100644 index 000000000..60ecbfa73 --- /dev/null +++ b/tests/corpus/6345-inspect-dyn-holes.ts @@ -0,0 +1,29 @@ +// Holey arrays reached through `unknown` print their holes as Node does: +// runs of holes as `` entries, which count toward the +// 100-entry limit, inside nested values too. +function show(label: string, value: unknown): void { + console.log(label, value); +} +const a: number[] = []; +a[2] = 1; +show("lead", a); +const t: string[] = ["x"]; +t[4] = "y"; +t.length = 7; +show("runs", t); +const one: number[] = [1]; +one[2] = 3; +show("single", one); +const wide: number[] = []; +for (let i = 0; i < 150; i += 2) wide[i] = i; +show("wide", wide); +const dense: number[] = []; +for (let i = 0; i < 120; i++) dense[i] = i; +dense[130] = 1; +show("dense", dense); +show("nested", { rows: [a, one] }); +const made: unknown = new Array(3); +show("ctor", made); +const holes: number[] = []; +holes.length = 3; +show("length", holes); diff --git a/tests/harness/corpus-costs/6345-inspect-dyn-holes.ts.json b/tests/harness/corpus-costs/6345-inspect-dyn-holes.ts.json new file mode 100644 index 000000000..d80769f29 --- /dev/null +++ b/tests/harness/corpus-costs/6345-inspect-dyn-holes.ts.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 2300 } From 27017ef0af96c004f9cade399a7edc6529c86c02 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:37:25 -0500 Subject: [PATCH 52/85] Map classes reached only through import type as their structural types A class whose module the program reaches only through `import type` never registers, and its instance type stayed unmapped: a field such as `items: Emitted | undefined` reported SC2009, and the class holding it SC1090. The program holds no instance of such a class, so its instance type now maps as the structural type it describes, like an interface: fields of the type hold undefined, object literals and arrays of them. A JavaScript class outside the program keeps the unmapped type, so its JSDoc-annotated slots still take the dynamic value. --- packages/compiler/src/frontend/type-mapper.ts | 14 +++++++---- .../6061-type-only-class-fields/main.ts.json | 7 ++++++ .../6061-type-only-class-fields/emitted.ts | 23 +++++++++++++++++++ .../6061-type-only-class-fields/holder.ts | 13 +++++++++++ .../6061-type-only-class-fields/main.ts | 17 ++++++++++++++ .../6061-type-only-class-fields/main.ts.json | 1 + 6 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6061-type-only-class-fields/main.ts.json create mode 100644 tests/corpus/6061-type-only-class-fields/emitted.ts create mode 100644 tests/corpus/6061-type-only-class-fields/holder.ts create mode 100644 tests/corpus/6061-type-only-class-fields/main.ts create mode 100644 tests/harness/corpus-costs/6061-type-only-class-fields/main.ts.json diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 437aba2b8..ea706992a 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2038,12 +2038,16 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // {}` — classNamer spells them "%anon" (unique per file), the same // name their collection registered. !classDecl.getSourceFile().isDeclarationFile && - checker.getConstructSignatures(widened).length === 0 + checker.getConstructSignatures(widened).length === 0 && + // A TypeScript class declared OUTSIDE the lowered program (reached + // through `import type` alone) never registers, and the program can + // hold no instance of it: its instance type maps below as the + // structural type it describes, like an interface. + (ctx.isProgramFile(classDecl.getSourceFile()) || isJsSourceFile(classDecl.getSourceFile())) ) { - // Declared OUTSIDE the lowered program (reached through the type world - // alone — jsdoc `typeof import()` over a never-required module): the - // class will never register, so the instance type is unmappable, not - // an object type naming a struct that cannot exist. + // A JavaScript class outside the lowered program (jsdoc `typeof + // import()` over a never-required module) stays unmappable: JSDoc + // types are not checked, so its slots fall back to the dynamic value. if (!ctx.isProgramFile(classDecl.getSourceFile())) return null; // The class inside a MIXIN function: per-call-site instantiations // share this one node — the CURRENT instantiation (collecting or diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6061-type-only-class-fields/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6061-type-only-class-fields/main.ts.json new file mode 100644 index 000000000..68bdcec61 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6061-type-only-class-fields/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6061-type-only-class-fields/holder.ts", + "/tests/corpus/6061-type-only-class-fields/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6061-type-only-class-fields/emitted.ts b/tests/corpus/6061-type-only-class-fields/emitted.ts new file mode 100644 index 000000000..aa2ebb2df --- /dev/null +++ b/tests/corpus/6061-type-only-class-fields/emitted.ts @@ -0,0 +1,23 @@ +export class Emitted { + readonly count: number; + readonly files: string[]; + constructor(count: number, files: string[]) { + this.count = count; + this.files = files; + } + summary(): string { + return `${this.count} files`; + } +} + +export class Sealed { + #secret = 1; + read(): number { + return this.#secret; + } +} + +export class Totals { + emitted = 0; + skipped = 0; +} diff --git a/tests/corpus/6061-type-only-class-fields/holder.ts b/tests/corpus/6061-type-only-class-fields/holder.ts new file mode 100644 index 000000000..d3cc1d1ab --- /dev/null +++ b/tests/corpus/6061-type-only-class-fields/holder.ts @@ -0,0 +1,13 @@ +import type { Emitted, Sealed, Totals } from "./emitted.ts"; + +export class Holder { + items: Emitted | undefined = undefined; + sealed: Sealed | undefined = undefined; + history: Emitted[] = []; + totals: Totals = { emitted: 0, skipped: 0 }; + record(result: Emitted): void { + this.items = result; + this.history.push(result); + this.totals.emitted += result.count; + } +} diff --git a/tests/corpus/6061-type-only-class-fields/main.ts b/tests/corpus/6061-type-only-class-fields/main.ts new file mode 100644 index 000000000..264bd4c6b --- /dev/null +++ b/tests/corpus/6061-type-only-class-fields/main.ts @@ -0,0 +1,17 @@ +// Classes that the program reaches only through `import type`: no module +// loads emitted.ts, so no instance of its classes exists, and their types +// describe the object shapes the program does hold, as an interface would. +// Fields of those types hold undefined, object literals, and arrays of them. +import { Holder } from "./holder.ts"; +import type { Emitted } from "./emitted.ts"; + +const holder = new Holder(); +console.log(holder.items === undefined, holder.sealed === undefined); +const result: Emitted = { count: 2, files: ["a.js", "b.js"], summary: () => "two files" }; +holder.record(result); +holder.record({ count: 0, files: [], summary: () => "none" }); +console.log(holder.items?.count, holder.items?.summary(), holder.history.length); +console.log(holder.history[0] === result, holder.history[0]!.files.join(",")); +console.log(JSON.stringify(holder.history.map((entry) => entry.count))); +holder.totals.skipped++; +console.log(holder.totals, JSON.stringify(holder.totals)); diff --git a/tests/harness/corpus-costs/6061-type-only-class-fields/main.ts.json b/tests/harness/corpus-costs/6061-type-only-class-fields/main.ts.json new file mode 100644 index 000000000..546e4a9e8 --- /dev/null +++ b/tests/harness/corpus-costs/6061-type-only-class-fields/main.ts.json @@ -0,0 +1 @@ +{ "plain": 6644, "san": 11367 } From 064b072f94bc88c3003d195f08c500595f4eb151 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:44:28 -0500 Subject: [PATCH 53/85] Match class capsules in union fields of checked unions A checked conversion to a union of object types tests each member's fields with the members' matchers. For a field of type `C | undefined` holding a class instance, the union's matcher found the instance's capsule, which is not a capsule of the union itself, and answered no (or matched the instance's plain-object view, which no class arm accepts), so no member matched and the conversion threw a TypeError. workerData read as such a union failed this way in every worker. A union now matches capsules of other types through its arms, where the class arm accepts the instance. --- packages/compiler/src/backend/llvm/dyn.ts | 4 +- .../6063-union-class-field-checks.ts.json | 6 ++ tests/corpus/6063-union-class-field-checks.ts | 65 +++++++++++++++++++ .../6063-union-class-field-checks.ts.json | 1 + 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6063-union-class-field-checks.ts.json create mode 100644 tests/corpus/6063-union-class-field-checks.ts create mode 100644 tests/harness/corpus-costs/6063-union-class-field-checks.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index cf7578c02..ae4f12fb8 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -762,7 +762,9 @@ export class LlDyn { B.terminate(`ret i1 true`); B.startBlock(lNext); } - if (t.kind !== "dyn") { + // A union matches a capsule of another type through its arms below: a + // class capsule in a `C | undefined` field matches the `C` arm. + if (t.kind !== "dyn" && t.kind !== "union") { const view = this.viewFn(wrapped); this.host.declare(`declare void @scr_dyn_release_v(ptr)`); const kind = this.kindOf(B, "%d"); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6063-union-class-field-checks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6063-union-class-field-checks.ts.json new file mode 100644 index 000000000..9e48b2910 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6063-union-class-field-checks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6063-union-class-field-checks.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6063-union-class-field-checks.ts b/tests/corpus/6063-union-class-field-checks.ts new file mode 100644 index 000000000..426182b28 --- /dev/null +++ b/tests/corpus/6063-union-class-field-checks.ts @@ -0,0 +1,65 @@ +// A checked conversion of a dynamic value to a union of object types picks +// the member whose fields match, including a field of type `C | undefined` +// that holds an instance of the class C: workerData read as such a union, +// and an unknown value asserted to it, give the instance or undefined. +import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads"; +import { publish } from "@scriptc/threads"; + +class Job { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +interface WithJob { + readonly kind: "job"; + readonly job: Job | undefined; +} +interface Plain { + readonly kind: "plain"; + readonly note: string; +} +interface Counted { + readonly kind: "counted"; + readonly count: number; + readonly last: Job | undefined; +} +type Data = WithJob | Plain | Counted; + +function describe(data: Data): string { + switch (data.kind) { + case "job": + return data.job === undefined ? "job none" : `job ${data.job.name}`; + case "plain": + return `plain ${data.note}`; + case "counted": + return `counted ${data.count} ${data.last?.name ?? "none"}`; + } +} + +function run(data: Data): Promise { + return new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: data }); + worker.on("message", (message) => console.log("worker:", message)); + worker.on("error", (error) => console.log("worker failed:", String(error))); + worker.on("exit", () => resolve()); + }); +} + +if (isMainThread) { + const values: unknown[] = [ + { kind: "job", job: new Job("local") }, + { kind: "job", job: undefined }, + { kind: "plain", note: "text" }, + { kind: "counted", count: 2, last: new Job("tail") }, + ]; + for (const value of values) console.log("local:", describe(value as Data)); + const shared = publish(new Job("shared")); + await run({ kind: "job", job: shared }); + await run({ kind: "job", job: undefined }); + await run({ kind: "plain", note: "worker" }); + await run({ kind: "counted", count: 3, last: shared }); +} else { + parentPort!.postMessage(describe(workerData as Data)); +} diff --git a/tests/harness/corpus-costs/6063-union-class-field-checks.ts.json b/tests/harness/corpus-costs/6063-union-class-field-checks.ts.json new file mode 100644 index 000000000..bba381595 --- /dev/null +++ b/tests/harness/corpus-costs/6063-union-class-field-checks.ts.json @@ -0,0 +1 @@ +{ "plain": 4152, "san": 6474 } From a1b077e16f0683136e8d0ef9604f38247bf29d61 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:45:03 -0500 Subject: [PATCH 54/85] Check private fields of function-local classes per class evaluation Each evaluation of a function-local class creates its own private names, while one compiled class serves every evaluation. A JavaScript class's prototype methods are closures per evaluation whose `this` is the call-site value, so a method taken from one evaluation could be called with another evaluation's instance (`a.next.call(b)`, a prototype read, Reflect.get with a receiver). Those calls read and wrote the other instance's fields, and `#x in this` answered true. Reads and writes of `this.#x` inside the closures also went through the property bag under the key "#x" instead of the native field. Private fields on a checked `this` now go through a brand check: the receiver must be an instance of the class or a subclass whose class environment is the closure's own evaluation, which the class's own binding names. A matching receiver reads and writes the native field; any other receiver throws Node's TypeError for the read or the write (after the assigned value evaluates), and `#x in this` answers false. Method values and accessors taken from typed instances of these classes resolve to the instance's own evaluation's prototype functions instead of a native thunk shared by every evaluation. The classes this applies to are narrowed to the shapes the check covers: JavaScript class declarations whose private members are instance fields used only on `this`. Private methods and accessors, class expressions, and TypeScript classes keep the "function-local classes with private brands" refusal. --- .../lowering/class-dynamic-dispatch.ts | 51 ++- .../frontend/lowering/class-method-values.ts | 18 +- .../lowering/expressions/property-access.ts | 17 + .../lowering/expressions/relational.ts | 37 +- .../frontend/lowering/expressions/updates.ts | 14 + .../src/frontend/lowering/lower-classes.ts | 35 +- .../src/frontend/lowering/lower-exprs.ts | 15 + .../compiler/src/frontend/lowering/lowerer.ts | 2 + .../src/frontend/lowering/private-brands.ts | 328 ++++++++++++++++++ .../local-class-private-boundaries.test.ts | 63 ++++ .../function-local-detached-methods.js.json | 6 + .../function-local-detached-methods.ts.json | 6 + ...function-local-private-evaluations.js.json | 6 + .../private-in-checked-primitives.js.json | 6 + .../corpus/function-local-detached-methods.js | 22 ++ .../corpus/function-local-detached-methods.ts | 19 + .../function-local-private-evaluations.js | 172 +++++++++ tests/corpus/private-in-checked-primitives.js | 28 ++ .../function-local-detached-methods.js.json | 1 + .../function-local-detached-methods.ts.json | 1 + ...function-local-private-evaluations.js.json | 1 + .../private-in-checked-primitives.js.json | 1 + 22 files changed, 821 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/private-brands.ts create mode 100644 packages/compiler/test/local-class-private-boundaries.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-evaluations.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/private-in-checked-primitives.js.json create mode 100644 tests/corpus/function-local-detached-methods.js create mode 100644 tests/corpus/function-local-detached-methods.ts create mode 100644 tests/corpus/function-local-private-evaluations.js create mode 100644 tests/corpus/private-in-checked-primitives.js create mode 100644 tests/harness/corpus-costs/function-local-detached-methods.js.json create mode 100644 tests/harness/corpus-costs/function-local-detached-methods.ts.json create mode 100644 tests/harness/corpus-costs/function-local-private-evaluations.js.json create mode 100644 tests/harness/corpus-costs/private-in-checked-primitives.js.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index fdab65bce..d2ef7f6cd 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -44,6 +44,7 @@ import { observedPrototypeMethod, reflectClassPrototype, } from "./class-prototypes.js"; +import { perEvaluationPrivateNames } from "./private-brands.js"; import { errorPropertyRead, errorPropertyWrite, @@ -4313,19 +4314,43 @@ export class ClassDynamicDispatch { loc, }; if (accessor && info.decl) { - const method = classMethodValue(lowerer, info.decl, info, member, loc); - if (method) { - const call: IrExpr = { - kind: "libCall", - fn: "dyn.reflectApply", - args: [ - lowerer.coerceToExpected(method, DYN), - actualReceiver, - { kind: "dynArrLit", elems: write ? [varRef("p.1", DYN, loc)] : [], type: DYN, loc }, - ], - type: DYN, - loc, - }; + // An accessor whose private names belong to one evaluation runs as + // the instance's own evaluation's prototype accessor. + const declarer = accessor.declarer; + const prototype = perEvaluationPrivateNames(declarer) + ? classPrototypeData(lowerer, declarer, loc, upcastTo(lowerer, receiver, declarer.def.name)) + : null; + const method = prototype ? null : classMethodValue(lowerer, info.decl, info, member, loc); + if (prototype || method) { + const call: IrExpr = prototype + ? { + kind: "libCall", + fn: write ? "dyn.reflectSet" : "dyn.reflectGet", + args: [ + prototype, + lowerer.coerceToExpected(key, DYN), + ...(write ? [varRef("p.1", DYN, loc)] : []), + actualReceiver, + ], + type: write ? BOOL : DYN, + loc, + } + : { + kind: "libCall", + fn: "dyn.reflectApply", + args: [ + lowerer.coerceToExpected(method!, DYN), + actualReceiver, + { + kind: "dynArrLit", + elems: write ? [varRef("p.1", DYN, loc)] : [], + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; return [ { kind: "if", diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 10572377e..795307c93 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -27,6 +27,7 @@ import { funcTypeFromParamShapes, type ParamShape } from "./call-signatures.js"; import { erasedGenericInstance, implicitDefaultInstance } from "./generic-functions.js"; import { errorToStringMethod } from "./error-methods.js"; import { classCallbackValue, isClassCallback } from "./class-callbacks.js"; +import { perEvaluationPrivateNames } from "./private-brands.js"; export interface ClassMethodValueSelection { expression: ts.PropertyAccessExpression; @@ -51,18 +52,20 @@ export function lowerClassMethodValue( } const loc = locOf(expr); const receiver = lowerer.lowerExpr(expr.expression); + const declared = findMethodOn(lowerer, info, method); // A JS checker may name a class while the value is held in checked // storage. Read its actual property so own replacements and getters - // participate, and never pass a dynamic value to a native selector. - if (receiver.type.kind === "dyn") + // participate, and never pass a dynamic value to a native selector. A + // method whose private names belong to one evaluation is that + // evaluation's prototype method as well. + if (receiver.type.kind === "dyn" || (!!declared && perEvaluationPrivateNames(declared.declarer))) return { kind: "dynKeyGet", - value: receiver, + value: lowerer.coerceToExpected(receiver, DYN), key: { kind: "strLit", value: method, type: STRING, loc }, type: DYN, loc, }; - const declared = findMethodOn(lowerer, info, method); // Abstract declarations have no prototype function. Concrete descendants // provide the extracted value through the same reachability-driven selector. let value = declared?.sig.abstract @@ -537,10 +540,13 @@ export function finalizeClassMethodValues(lowerer: Lowerer, functions: IrFunctio !(expr.kind === "closure" && expr.captures.includes(thisParam.localId)), }); if (usesThis) continue; - // A method that never uses this remains callable when detached. + // A method that never uses this remains callable when detached. Without + // class captures it needs no class environment, so the detached copy + // drops the (empty) capture list, which requires a receiver. const name = `%method.receiverless:${target.name}`; + const { classCaptures: _classCaptures, ...detached } = target; functions.push({ - ...target, + ...detached, name, params: target.params.slice(1), locals: target.locals.filter((local) => local.id !== thisParam.localId), diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 7f0b2ddbb..dbbb701df 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,5 +1,6 @@ /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import * as ts from "../../ts7/adapter.js"; +import { localPrivateBrandClass } from "../private-brands.js"; import { type Lowerer, PoisonError, jsFuncNameOf, neverTaintedJsType, own } from "../lowerer.js"; import { ARRAY_METHODS, @@ -3087,6 +3088,22 @@ export function fieldTarget( fieldType: F64, }; } + // `this.#x` in a function-local class's prototype method: the field of + // the brand-checked receiver (checkedPrivateReceiver). + if ( + ts.isPrivateIdentifier(access.name) && + access.expression.kind === ts.SyntaxKind.ThisKeyword && + lowerer.resolveThis()?.type.kind === "dyn" + ) { + const owner = localPrivateBrandClass(lowerer, access.name); + if (owner) { + const ownerType: IrType & { kind: "object" } = { + kind: "object", + className: owner.info.def.name, + }; + return classFieldTarget(lowerer, access.expression, ownerType, access.name.text); + } + } const stored = ts.isIdentifier(access.expression) ? (lowerer.peekLocal(access.expression)?.type ?? lowerer.globalOf(access.expression)?.type) : undefined; diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 3c05b0174..b92c5e7e5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1,5 +1,11 @@ /* instanceof and in, including private-name and runtime-key in. */ import * as ts from "../../ts7/adapter.js"; +import { + checkedPrivateIn, + localPrivateBrandClass, + primitiveOperand, + privateInMessage, +} from "../private-brands.js"; import type { Lowerer } from "../lowerer.js"; import { BOOL, @@ -23,7 +29,7 @@ import { refuseWebAssemblyUse } from "../builtins/webassembly.js"; import { lowerSocketInstanceOf } from "../lower-server.js"; import { builtinClassRefOf } from "../builtins/class-values.js"; import { lowerZlibInstanceOf } from "../builtins/compression.js"; -import { varRef } from "../../../ir/build.js"; +import { numLit, strLit, varRef } from "../../../ir/build.js"; import { lowerDisposableStackInstanceOf } from "../builtins/disposable-stack.js"; import { lowerBlobInstanceof } from "../builtins/blob.js"; import { lowerKeyObjectInstanceof } from "../builtins/key-object.js"; @@ -1045,6 +1051,18 @@ function lowerPrivateIn( // reference carries the class it was boxed under: the declarer or a // descendant answers true; an ancestor answers per instance. if (recv.type.kind === "dyn" && recv.kind === "varRef" && isJsSourceFile(expr.getSourceFile())) { + // A function-local class has one brand per evaluation. + if (info.localClass) { + const owner = localPrivateBrandClass(lowerer, priv); + if (!owner) { + lowerer.unsupported( + "SC1090", + expr, + `'${pname} in …' inside a function-local class that is not a named declaration`, + ); + } + return checkedPrivateIn(lowerer, owner.info, owner.decl, pname, recv, loc); + } const typedRefIs = (className: string): IrExpr => ({ kind: "libCall", fn: "dyn.typedRefIs", @@ -1080,7 +1098,22 @@ function lowerPrivateIn( loc, }); } - return test!; + // Only objects carry private names: a primitive operand throws Node's + // TypeError. + return { + kind: "ternary", + cond: primitiveOperand(recv, loc), + then: { + kind: "libCall", + fn: "error.nodeThrow", + args: [numLit(1, loc), strLit("", loc), privateInMessage(pname, recv, loc)], + type: BOOL, + loc, + }, + else_: test!, + type: BOOL, + loc, + }; } if (recv.type.kind !== "object") { lowerer.unsupported( diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 61943eff4..8b98389b6 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -1,5 +1,6 @@ /* Increment, decrement and prefix unary operators, and compound field updates. */ import * as ts from "../../ts7/adapter.js"; +import { isPrivateWriteReceiver } from "../private-brands.js"; import type { Lowerer } from "../lowerer.js"; import { type FieldTarget, @@ -524,6 +525,19 @@ export function fieldSetStmt( loc: SrcLoc, blame: ts.Node, ): IrStmt { + // A write through a brand-checked receiver (checkedPrivateReceiver): the + // value evaluates first, then the brand check throws, as in Node. + if (isPrivateWriteReceiver(target.obj) && value.kind !== "varRef") { + const stored = lowerer.declareHiddenLocal("%privateWriteValue", value.type); + return { + kind: "block", + body: [ + { kind: "varDecl", localId: stored.id, init: value, loc }, + fieldSetStmt(lowerer, target, varRef(stored.id, stored.type, loc), loc, blame), + ], + loc, + }; + } // A member write through an unassigned deferred-init field: the value // still evaluates first, then the write throws Node's TypeError. const deferredSlot = diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8fddb1a60..6589d7c04 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -5463,12 +5463,33 @@ function namedEvaluationName(expr: ts.ClassLikeDeclaration): string { } /** Each evaluation of a function-local class creates fresh private names, - * while the compiled class shares one layout across evaluations. They agree - * when every private name is used only on `this` inside the class's own - * instance members (`this.#x`, `#x in this`), outside nested classes and - * non-arrow functions, and the class has no static members that could hand - * instances across evaluations: each instance then meets only the methods - * of its own evaluation (undici's webidl FastIterableIterator). */ + * while the compiled class shares one layout across evaluations. A + * JavaScript class's prototype methods are closures per evaluation, so the + * names agree when every private member is an instance field and every use + * is on `this` (privateNamesOnlyOnThis): native methods only run on their + * own evaluation's instances, and the closures, which receive any `this`, + * check the receiver's evaluation against the class's own binding + * (private-brands.ts), so the class must be a named declaration (undici's + * webidl FastIterableIterator). The checker rejects writes to that binding. */ +function localPrivateFieldsAgree(expr: ts.ClassLikeDeclaration): boolean { + if (!isJsSourceFile(expr.getSourceFile()) || !ts.isClassDeclaration(expr) || !expr.name) + return false; + if ( + expr.members.nodes.some( + (member) => + member.name !== undefined && + ts.isPrivateIdentifier(member.name) && + !ts.isPropertyDeclaration(member), + ) + ) + return false; + return privateNamesOnlyOnThis(expr); +} + +/** Every private name is used only on `this` inside the class's own instance + * members (`this.#x`, `#x in this`), outside nested classes and non-arrow + * functions, and the class has no static members that could hand instances + * across evaluations. */ function privateNamesOnlyOnThis(expr: ts.ClassLikeDeclaration): boolean { let ok = true; for (const member of expr.members.nodes) { @@ -5548,7 +5569,7 @@ export function lowerClassExpressionInfo( if ( local && expr.members.nodes.some((member) => member.name && ts.isPrivateIdentifier(member.name)) && - !privateNamesOnlyOnThis(expr) + !localPrivateFieldsAgree(expr) ) { lowerer.unsupported("SC1090", expr, "function-local classes with private brands"); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index ac51cfbe6..6cbe5d7a4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -8,6 +8,7 @@ import { InternalCompilerError } from "../../errors.js"; * ToBoolean/ToString coercion helpers, and field/element reads and writes * (FieldTarget). */ import * as ts from "../ts7/adapter.js"; +import { checkedPrivateReceiver, localPrivateBrandClass } from "./private-brands.js"; import { constituentTypes } from "../ts7/checker.js"; import type { Lowerer } from "./lowerer.js"; import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js"; @@ -458,6 +459,20 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const local = lowerer.resolveThis(); if (local) { const value: IrExpr = { kind: "varRef", localId: local.id, type: local.type, loc }; + // `this.#x` in a function-local class's prototype method, whose `this` + // is the call-site value: the field lives on the native instance of + // this evaluation (checkedPrivateReceiver). + const access = expr.parent; + if ( + local.type.kind === "dyn" && + ts.isPropertyAccessExpression(access) && + access.expression === expr && + ts.isPrivateIdentifier(access.name) + ) { + const owner = localPrivateBrandClass(lowerer, access.name); + if (owner) + return checkedPrivateReceiver(lowerer, owner.info, owner.decl, access, value, loc); + } const initialized = lowerer.ctx.superInitialized; if (initialized) return { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 792696130..7144e574e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -129,6 +129,7 @@ import { } from "../tier-analysis.js"; import { DeferredModuleInitializers } from "./deferred-module-initializers.js"; import { finalizeClassMethodValues } from "./class-method-values.js"; +import { finalizePrivateBrands } from "./private-brands.js"; import { type ClassSymbolKey, tableKeyFieldType } from "./symbol-fields.js"; import type { HttpClientFnBinding } from "./lower-server.js"; export type { WidthLift } from "./width-lift.js"; @@ -6926,6 +6927,7 @@ export class Lowerer { finishModule(functions: IrFunction[]): LowerResult { if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); + finalizePrivateBrands(this, functions); trackClassFieldCreation(this, functions); // Globals typed by a class that never REGISTERED (a JS class whose diff --git a/packages/compiler/src/frontend/lowering/private-brands.ts b/packages/compiler/src/frontend/lowering/private-brands.ts new file mode 100644 index 000000000..e7b3edbd8 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/private-brands.ts @@ -0,0 +1,328 @@ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + RUNTIME_ERROR_CLASSES, + STRING, + typeKey, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; +import { helperLoc, strLit, varRef } from "../../ir/build.js"; +import { isJsSourceFile } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import type { ClassInfo } from "./lower-classes.js"; + +/** How a brand helper uses its receiver: `in` answers `#x in v`, `read` and + * `write` return the receiver as the declaring class for a field access. */ +type BrandUse = "in" | "read" | "write"; + +const PREFIX = "%private."; + +/** A function-local JavaScript class with private names. Its prototype + * methods and accessors exist once per evaluation and check their receiver's + * evaluation, so a method or accessor taken from an instance is the function + * of that instance's own evaluation. */ +export function perEvaluationPrivateNames(info: ClassInfo): boolean { + const declaration = info.localClass ? info.decl : null; + return ( + !!declaration && + isJsSourceFile(declaration.getSourceFile()) && + declaration.members.nodes.some( + (member) => member.name !== undefined && ts.isPrivateIdentifier(member.name), + ) + ); +} + +/** The function-local class declaring a private name, when its private names + * need a per-evaluation brand check: the nearest enclosing class that + * declares the name, as JavaScript scoping resolves it. */ +export function localPrivateBrandClass( + lowerer: Lowerer, + priv: ts.PrivateIdentifier, +): { info: ClassInfo; decl: ts.ClassDeclaration & { name: ts.Identifier } } | null { + for (let node: ts.Node | undefined = priv.parent; node; node = node.parent) { + if (!ts.isClassDeclaration(node) && !ts.isClassExpression(node)) continue; + const declares = node.members.nodes.some((member) => { + const name = (member as { name?: ts.PropertyName }).name; + return name !== undefined && ts.isPrivateIdentifier(name) && name.text === priv.text; + }); + if (!declares) continue; + if (!ts.isClassDeclaration(node) || !node.name) return null; + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + const info = + lowerer.currentClass?.decl === node + ? lowerer.currentClass + : symbol + ? lowerer.classBySymbol.get(symbol) + : undefined; + if (!info?.localClass) return null; + return { info, decl: node as ts.ClassDeclaration & { name: ts.Identifier } }; + } + return null; +} + +/** Each evaluation of a function-local class creates fresh private names, + * while one compiled class serves every evaluation. Its JavaScript prototype + * methods are closures per evaluation whose `this` is a checked value, so a + * private name on `this` is present exactly when the receiver is an instance + * of the class (or a subclass) whose `%classEnvironment` field holds the + * class value of the closure's own evaluation. The class's own binding names + * that value (lowerClassExpressionInfo accepts only named declarations, whose + * binding the checker keeps from being assigned). */ +export function checkedPrivateReceiver( + lowerer: Lowerer, + info: ClassInfo, + decl: ts.ClassDeclaration & { name: ts.Identifier }, + access: ts.PropertyAccessExpression, + receiver: IrExpr, + loc: SrcLoc, +): IrExpr { + const use: BrandUse = isPrivateWriteTarget(access) ? "write" : "read"; + return brandCall(lowerer, info, decl, use, access.name.text, receiver, loc); +} + +/** Whether a checked value is a primitive: `#x in v` throws for those + * before any brand lookup. */ +export function primitiveOperand(value: IrExpr, loc: SrcLoc): IrExpr { + return (["nullish", "number", "string", "boolean", "bigint", "symbol"] as const) + .map((test): IrExpr => ({ kind: "dynTest", test, value, type: BOOL, loc })) + .reduce((left, right) => ({ kind: "logical", op: "||", left, right, type: BOOL, loc })); +} + +/** Node's TypeError message for `#x in v` on a primitive, which spells the + * value as String(v) does. */ +export function privateInMessage(name: string, value: IrExpr, loc: SrcLoc): IrExpr { + return { + kind: "strConcat", + left: strLit(`Cannot use 'in' operator to search for '${name}' in `, loc), + right: { kind: "libCall", fn: "dyn.stringConstructor", args: [value], type: STRING, loc }, + type: STRING, + loc, + }; +} + +/** A receiver checkedPrivateReceiver built for a field write. */ +export function isPrivateWriteReceiver(expr: IrExpr): boolean { + return expr.kind === "call" && expr.callee.startsWith(`${PREFIX}write:`); +} + +/** `#x in v` for a checked value inside a function-local class: the brand + * test above, after the TypeError a primitive operand throws. */ +export function checkedPrivateIn( + lowerer: Lowerer, + info: ClassInfo, + decl: ts.ClassDeclaration & { name: ts.Identifier }, + name: string, + receiver: IrExpr, + loc: SrcLoc, +): IrExpr { + return brandCall(lowerer, info, decl, "in", name, receiver, loc); +} + +function brandCall( + lowerer: Lowerer, + info: ClassInfo, + decl: ts.ClassDeclaration & { name: ts.Identifier }, + use: BrandUse, + name: string, + receiver: IrExpr, + loc: SrcLoc, +): IrExpr { + const className = info.def.name; + const classType: IrType = { kind: "classval", className }; + const resultType: IrType = use === "in" ? BOOL : { kind: "object", className }; + const helper = `${PREFIX}${use}:${className}:${name}`; + if (!lowerer.valueHelpers.has(helper)) { + const at = helperLoc(loc); + lowerer.liftedFns.push({ + name: helper, + params: [ + { localId: "value.0", name: "value", type: DYN }, + { localId: "class.0", name: "class", type: classType }, + ], + returnType: resultType, + locals: [ + { id: "value.0", name: "value", type: DYN, mutable: false }, + { id: "class.0", name: "class", type: classType, mutable: false }, + ], + // finalizePrivateBrands fills the body once every class is collected, + // including subclasses declared after this use. + body: [], + loc: at, + }); + lowerer.valueHelpers.set(helper, helper); + } + const evaluation = lowerer.classValueRef(info, decl.name); + return { kind: "call", callee: helper, args: [receiver, evaluation], type: resultType, loc }; +} + +/** A plain assignment, a destructuring target, or a for-in/of target writes + * the field. Compound assignments and updates read it first, and Node + * reports the failed read. */ +function isPrivateWriteTarget(access: ts.PropertyAccessExpression): boolean { + let node: ts.Node = access; + for (;;) { + const parent = node.parent; + if (ts.isParenthesizedExpression(parent)) { + node = parent; + continue; + } + if (ts.isBinaryExpression(parent)) + return parent.left === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; + if (ts.isForOfStatement(parent) || ts.isForInStatement(parent)) + return parent.initializer === node; + // Inside a destructuring pattern: keep walking to the assignment. + if ( + ts.isArrayLiteralExpression(parent) || + ts.isSpreadElement(parent) || + (ts.isPropertyAssignment(parent) && parent.initializer === node) || + ts.isObjectLiteralExpression(parent) || + ts.isSpreadAssignment(parent) + ) { + node = parent; + continue; + } + return false; + } +} + +/** Fill the brand helpers: one branch per class a checked reference to an + * instance can be boxed under (the declarer, its subclasses, and its + * ancestors when the instance is at or below the declarer). */ +export function finalizePrivateBrands(lowerer: Lowerer, functions: IrFunction[]): void { + for (const fn of functions) { + if (!fn.name.startsWith(PREFIX) || fn.body.length > 0) continue; + // `%private.::<#name>`; class names may contain colons. + const spec = fn.name.slice(PREFIX.length); + const use = spec.slice(0, spec.indexOf(":")) as BrandUse; + const className = spec.slice(use.length + 1, spec.lastIndexOf(":")); + const name = spec.slice(spec.lastIndexOf(":") + 1); + const loc = fn.loc; + const value = varRef("value.0", DYN, loc); + const evaluation = varRef("class.0", { kind: "classval", className }, loc); + const ownerType: IrType = { kind: "object", className }; + const body: IrStmt[] = []; + let matches = 0; + for (const candidate of lowerer.classes.values()) { + const candidateName = candidate.def.name; + if (RUNTIME_ERROR_CLASSES.has(candidateName)) continue; + const below = candidateName === className || lowerer.isSubclassOf(candidateName, className); + const above = lowerer.isSubclassOf(className, candidateName); + if (!below && !above) continue; + const candidateType: IrType = { kind: "object", className: candidateName }; + const checked: IrExpr = { kind: "dynCheck", value, type: candidateType, loc }; + const local = `receiver.${matches++}`; + fn.locals.push({ id: local, name: "receiver", type: ownerType, mutable: false }); + const receiver = varRef(local, ownerType, loc); + const sameEvaluation: IrExpr = { + kind: "bin", + op: "===", + left: { + kind: "fieldGet", + obj: receiver, + className, + field: `%classEnvironment:${className}`, + type: evaluation.type, + loc, + }, + right: evaluation, + type: BOOL, + loc, + }; + const match = (typed: IrExpr): IrStmt[] => [ + { kind: "varDecl", localId: local, init: typed, loc }, + { + kind: "if", + cond: sameEvaluation, + then: [ + { + kind: "return", + value: use === "in" ? { kind: "boolLit", value: true, type: BOOL, loc } : receiver, + loc, + }, + ], + else_: null, + loc, + }, + ]; + const then: IrStmt[] = below + ? match( + candidateName === className + ? checked + : { kind: "upcast", value: checked, type: ownerType, loc }, + ) + : [ + { + kind: "if", + cond: { kind: "instanceOf", value: checked, className, type: BOOL, loc }, + then: match({ kind: "downcast", value: checked, type: ownerType, loc }), + else_: null, + loc, + }, + ]; + body.push({ + kind: "if", + cond: { + kind: "libCall", + fn: "dyn.typedRefIs", + args: [value, strLit(typeKey(candidateType), loc)], + type: BOOL, + loc, + }, + then, + else_: null, + loc, + }); + } + const typeError = (message: IrExpr): IrStmt => ({ + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [message], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }); + if (use === "in") { + body.push( + { + kind: "if", + cond: primitiveOperand(value, loc), + then: [typeError(privateInMessage(name, value, loc))], + else_: null, + loc, + }, + { kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc }, + ); + } else { + const verb = use === "read" ? "reading" : "setting"; + const action = use === "read" ? "Cannot read properties" : "Cannot set properties"; + for (const unit of ["undefined", "null"] as const) { + body.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value, type: BOOL, loc }, + then: [typeError(strLit(`${action} of ${unit} (${verb} '${name}')`, loc))], + else_: null, + loc, + }); + } + body.push( + typeError( + strLit( + use === "read" + ? `Cannot read private member ${name} from an object whose class did not declare it` + : `Cannot write private member ${name} to an object whose class did not declare it`, + loc, + ), + ), + ); + } + fn.body = body; + } +} diff --git a/packages/compiler/test/local-class-private-boundaries.test.ts b/packages/compiler/test/local-class-private-boundaries.test.ts new file mode 100644 index 000000000..fdbe2e8f8 --- /dev/null +++ b/packages/compiler/test/local-class-private-boundaries.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { analyze } from "../src/index.js"; + +function refusals(file: string, source: string): string[] { + const dir = mkdtempSync(join(tmpdir(), "scriptc-local-class-private-")); + try { + const entry = join(dir, file); + writeFileSync(entry, source); + const { coverage } = analyze(entry); + return [...coverage.diagnostics, ...(coverage.runtimeFences ?? [])].map((d) => d.message); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// Function-local JavaScript classes compile private fields used only on +// `this` when the class is a named declaration: each evaluation's prototype +// methods check the receiver's evaluation against that binding. Private +// methods and accessors, class expressions, private names on other objects, +// and TypeScript classes keep the refusal. +test.each([ + [ + "a private method", + "main.js", + "function make() { class Box { #read() { return 1; } read() { return this.#read(); } } return new Box(); } console.log(make().read());", + ], + [ + "a private accessor", + "main.js", + "function make() { class Box { get #value() { return 1; } read() { return this.#value; } } return new Box(); } console.log(make().read());", + ], + [ + "a class expression", + "main.js", + "function make() { const Box = class { #value = 1; read() { return this.#value; } }; return new Box(); } console.log(make().read());", + ], + [ + "a private name on another object", + "main.js", + "function make() { class Box { #value = 1; has(other) { return #value in other; } } return new Box(); } console.log(make().has({}));", + ], + [ + "a TypeScript class", + "main.ts", + "function make() { class Box { #value = 1; read(): number { return this.#value; } } return new Box(); } console.log(make().read());", + ], +] as const)("function-local private names keep the refusal with %s", (_name, file, source) => { + expect(refusals(file, source)).toContainEqual( + expect.stringContaining("function-local classes with private brands"), + ); +}); + +test("function-local private fields used on this compile", () => { + expect( + refusals( + "main.js", + "function make() { class Box { #value = 1; read() { return this.#value; } has() { return #value in this; } } return new Box(); } const box = make(); console.log(box.read(), box.has());", + ), + ).toEqual([]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.js.json new file mode 100644 index 000000000..9eb8fdf73 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-local-detached-methods.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.ts.json new file mode 100644 index 000000000..6ef52390d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-detached-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-local-detached-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-evaluations.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-evaluations.js.json new file mode 100644 index 000000000..0216d2c2f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-local-private-evaluations.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-local-private-evaluations.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/private-in-checked-primitives.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/private-in-checked-primitives.js.json new file mode 100644 index 000000000..1e5b83999 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/private-in-checked-primitives.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/private-in-checked-primitives.js" + ], + "diags": [] +} diff --git a/tests/corpus/function-local-detached-methods.js b/tests/corpus/function-local-detached-methods.js new file mode 100644 index 000000000..9bde77a19 --- /dev/null +++ b/tests/corpus/function-local-detached-methods.js @@ -0,0 +1,22 @@ +// A method of a function-local class that never reads `this` keeps working +// when it is taken off an instance and called detached or with another +// receiver. +'use strict'; + +function make(label) { + class Box { + one() { + return 1; + } + + named() { + return `box ${label}`; + } + } + return new Box(); +} + +const box = make('a'); +const one = box.one; +console.log(one(), box.one.call(null), box.one.call({}), box.named()); +console.log([box, make('b')].map((b) => b.one()).join(',')); diff --git a/tests/corpus/function-local-detached-methods.ts b/tests/corpus/function-local-detached-methods.ts new file mode 100644 index 000000000..ba4d3ba3c --- /dev/null +++ b/tests/corpus/function-local-detached-methods.ts @@ -0,0 +1,19 @@ +// A method of a function-local class that never reads `this` keeps working +// when it is taken off an instance and called detached or with another +// receiver. +function make(label: string) { + class Box { + one(): number { + return 1; + } + + named(): string { + return `box ${label}`; + } + } + return new Box(); +} + +const box = make("a"); +const one = box.one; +console.log(one(), box.one.call(null), box.one.call({}), box.named()); diff --git a/tests/corpus/function-local-private-evaluations.js b/tests/corpus/function-local-private-evaluations.js new file mode 100644 index 000000000..6e18a4766 --- /dev/null +++ b/tests/corpus/function-local-private-evaluations.js @@ -0,0 +1,172 @@ +// Each evaluation of a function-local class creates its own private names. +// A method taken from one evaluation and called with an instance of another +// (through call, a prototype read, or a checked receiver) finds no brand: +// field reads and writes throw Node's TypeErrors and `#x in this` answers +// false. Calls within one evaluation share the instance's fields. +'use strict'; + +function make(label) { + class Counter { + #count = 0; + #label = label; + #format = (n) => `${label}#${n}`; + + bump() { + this.#count = this.#count + 1; + return `${this.#label}:${this.#count}`; + } + + add(n) { + this.#count += n; + return this.#count; + } + + increment() { + this.#count++; + return this.#count; + } + + reset() { + [this.#count] = [0]; + return this.#count; + } + + assign() { + return (this.#count = 7); + } + + order(note) { + this.#count = note(); + return this.#count; + } + + has() { + return #count in this; + } + + later() { + return () => this.#label; + } + + describe() { + return this.#format(this.#count); + } + + get count() { + return this.#count; + } + + set count(value) { + this.#count = value; + } + } + + class Tagged extends Counter { + tag() { + return `tagged ${this.bump()}`; + } + } + + return { counter: () => new Counter(), tagged: () => new Tagged() }; +} + +function attempt(label, fn) { + try { + console.log(label, fn()); + } catch (e) { + console.log(label, e instanceof TypeError, e.message); + } +} + +const one = make('one'); +const two = make('two'); +const a = one.counter(); +const b = two.counter(); +const a2 = one.counter(); + +console.log(a.bump(), a.bump(), b.bump(), a2.bump()); +console.log(a.add(5), a.increment(), a.has(), a.later()()); + +// Borrowed within one evaluation: the fields are the instance's own. +const bump = a.bump; +console.log(bump.call(a2), a2.bump(), bump.call(a)); +console.log(Object.getPrototypeOf(a).bump.call(a2)); + +// Borrowed across evaluations. +attempt('call read', () => a.bump.call(b)); +attempt('compound', () => a.add.call(b, 1)); +attempt('update', () => a.increment.call(b)); +attempt('destructure', () => a.reset.call(b)); +attempt('assign value', () => a.assign.call(b)); +attempt('value first', () => + a.order.call(b, () => { + console.log('value evaluated'); + return 1; + }), +); +attempt('proto read', () => Object.getPrototypeOf(a).bump.call(b)); +attempt('has other', () => a.has.call(b)); +attempt('has own', () => a.has.call(a2)); +attempt('arrow', () => a.later.call(b)()); +attempt('field call', () => a.describe.call(b)); +attempt('field call own', () => a.describe.call(a2)); +attempt('apply', () => a.bump.apply(b)); +attempt('bind', () => a.bump.bind(b)()); +attempt('Reflect.apply', () => Reflect.apply(a.bump, b, [])); +const proto = Object.getPrototypeOf(a); +const bumpDescriptor = /** @type {any} */ (Object.getOwnPropertyDescriptor(proto, 'bump')); +const countDescriptor = /** @type {any} */ (Object.getOwnPropertyDescriptor(proto, 'count')); +attempt('descriptor value', () => bumpDescriptor.value.call(b)); +attempt('getter', () => countDescriptor.get.call(b)); +attempt('setter', () => countDescriptor.set.call(b, 5)); +attempt('Reflect.get', () => Reflect.get(a, 'count', b)); +attempt('Reflect.set', () => Reflect.set(a, 'count', 3, b)); +attempt('own getter', () => [countDescriptor.get.call(a2), Reflect.get(a, 'count', a2)].join(' ')); +console.log(b.bump(), b.count, (b.count = 10), b.bump()); + +// Receivers that are not instances. +for (const recv of [{}, [], undefined, null, 1, 'x']) { + attempt(`read ${String(recv)}`, () => a.bump.call(recv)); + attempt(`write ${String(recv)}`, () => a.reset.call(recv)); + attempt(`has ${String(recv)}`, () => a.has.call(recv)); +} + +// A subclass declared after the class in the same function carries the +// brand of its own evaluation. +const t1 = one.tagged(); +const t2 = two.tagged(); +console.log(t1.tag(), t2.tag(), t1.has(), a.has.call(t1), a.has.call(t2)); +attempt('subclass across', () => a.bump.call(t2)); +attempt('subclass own', () => b.bump.call(t2)); + +// undici's iterator guard: the error comes from the method itself. +function createIterator(name) { + class FastIterableIterator { + #target; + #index = 0; + + constructor(target) { + this.#target = target; + } + + next() { + if (typeof this !== 'object' || this === null || !(#target in this)) { + throw new TypeError(`'next' called on an object that does not implement interface ${name} Iterator.`); + } + const index = this.#index; + if (index >= this.#target.length) return { value: undefined, done: true }; + this.#index = index + 1; + return { value: this.#target[index], done: false }; + } + } + return (target) => new FastIterableIterator(target); +} + +const headers = createIterator('Headers'); +const params = createIterator('Params'); +const it = headers(['a', 'b']); +const next = it.next; +console.log(JSON.stringify(next.call(it)), JSON.stringify(it.next()), JSON.stringify(next.call(it))); +for (const recv of [headers(['c']), params(['d']), {}, null]) { + attempt('next', () => JSON.stringify(next.call(recv))); +} diff --git a/tests/corpus/private-in-checked-primitives.js b/tests/corpus/private-in-checked-primitives.js new file mode 100644 index 000000000..f73902b74 --- /dev/null +++ b/tests/corpus/private-in-checked-primitives.js @@ -0,0 +1,28 @@ +// `#x in v` where v is a checked value: objects answer by brand, and a +// primitive operand throws Node's TypeError, which names the value as +// String(v) spells it. +'use strict'; + +class Base {} + +class Box extends Base { + #value = 1; + + static is(v) { + return #value in v; + } +} + +class Sub extends Box {} + +console.log(Box.is(new Box()), Box.is(new Sub()), Box.is(new Base())); +console.log(Box.is({}), Box.is([]), Box.is(function () {}), Box.is(Box)); + +const primitives = [1, -0, NaN, 1.5, 'str', '', true, false, undefined, null, 10n, -5n, Symbol('s'), Symbol()]; +for (const v of primitives) { + try { + console.log('no throw', Box.is(v)); + } catch (e) { + console.log(e instanceof TypeError, e.message); + } +} diff --git a/tests/harness/corpus-costs/function-local-detached-methods.js.json b/tests/harness/corpus-costs/function-local-detached-methods.js.json new file mode 100644 index 000000000..f839807c5 --- /dev/null +++ b/tests/harness/corpus-costs/function-local-detached-methods.js.json @@ -0,0 +1 @@ +{ "plain": 3316, "san": 4688 } diff --git a/tests/harness/corpus-costs/function-local-detached-methods.ts.json b/tests/harness/corpus-costs/function-local-detached-methods.ts.json new file mode 100644 index 000000000..19907d4e4 --- /dev/null +++ b/tests/harness/corpus-costs/function-local-detached-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 2314, "san": 3117 } diff --git a/tests/harness/corpus-costs/function-local-private-evaluations.js.json b/tests/harness/corpus-costs/function-local-private-evaluations.js.json new file mode 100644 index 000000000..c75974ffd --- /dev/null +++ b/tests/harness/corpus-costs/function-local-private-evaluations.js.json @@ -0,0 +1 @@ +{ "plain": 6264, "san": 10866 } diff --git a/tests/harness/corpus-costs/private-in-checked-primitives.js.json b/tests/harness/corpus-costs/private-in-checked-primitives.js.json new file mode 100644 index 000000000..ce2fa848e --- /dev/null +++ b/tests/harness/corpus-costs/private-in-checked-primitives.js.json @@ -0,0 +1 @@ +{ "plain": 2316, "san": 2225 } From ce1010a531734cd0ab152526ec706b6769f70b51 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 22:48:39 -0500 Subject: [PATCH 55/85] Split emitter.ts's statement, function and module emission into their own files emitter.ts had grown to 11.6k lines, and its import block and its large emission methods were edited by most changes to the LLVM backend, so concurrent changes conflicted there: 17 merges into main resolved it in two weeks, more than any other compiler file. 26 LlEmitter methods move, as is, into functions of the emitter in four files next to it: emit-module.ts (module parts, helper and library definitions), emit-statements.ts (the statement switch, try/catch, switch, loop copies), emit-function.ts (function bodies and generator and async scaffolding) and receivers.ts (receiver reads and the borrow analyses). Each method keeps a one-line stub in the class, so callers are unchanged, and imports that only the moved bodies use move with them. The move changes no behavior: the zero-cost gate compiles the benchmarks, the self-hosted compiler and the corpus to identical IR, LLVM and executables. --- .../src/backend/llvm/emit-function.ts | 1347 ++++ .../compiler/src/backend/llvm/emit-module.ts | 2245 ++++++ .../src/backend/llvm/emit-statements.ts | 1985 ++++++ packages/compiler/src/backend/llvm/emitter.ts | 6254 +---------------- .../compiler/src/backend/llvm/receivers.ts | 409 ++ 5 files changed, 6239 insertions(+), 6001 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/emit-function.ts create mode 100644 packages/compiler/src/backend/llvm/emit-module.ts create mode 100644 packages/compiler/src/backend/llvm/emit-statements.ts create mode 100644 packages/compiler/src/backend/llvm/receivers.ts diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts new file mode 100644 index 000000000..78f208ff1 --- /dev/null +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -0,0 +1,1347 @@ +/* LlEmitter's function emission: function bodies, generator and async scaffolding, async awaits, WASI fulfillment and trampolines. */ +import { + type LlArgPackAndTrampolinePrologue, + type LlEmitter, + type LlScopeEntry, + SUSPENDS, + UNOPTIMIZED_BOX_SLOTS, + boxSlotCount, +} from "./emitter.js"; +import { type IrFunction, type IrType, isRefCounted, typeKey } from "../../ir/ir.js"; +import { BlockBuilder } from "./blocks.js"; +import { findPrivateSplitLocals } from "./split-loops.js"; +import { + conditionalLoopWrites, + initializerLocalCandidates, + withInitializerBindings, + withInitializerLocals, +} from "../../ir/initializer-bindings.js"; +import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js"; +import { constructorClass } from "../../ir/final-fields.js"; +import { findInitializedByteLoopBindings } from "./byte-windows.js"; +import { findLoopArrayBorrows } from "./loop-array-borrows.js"; +import { findCallArrayReads, findLocalArrayReads } from "./local-array-reads.js"; +import { findScalarStringSlices, findScratchStringSlices } from "./string-slices.js"; +import { findMapReadLifetimes, findReadNumberMapWrites } from "./map-read-lifetimes.js"; +import { findMapEntryFusions } from "./map-entry-fusion.js"; +import { findLocalStackUnions } from "./stack-unions.js"; +import { findLocalStackObjects, findReassignedStackObjects } from "./stack-objects.js"; +import { findLocalUnionStorage } from "./local-union-storage.js"; +import { INT32_RANGE, analyzeIntegerRanges } from "../../ir/integer-ranges.js"; +import { findBytesBounds } from "./bytes-bounds.js"; +import { findInt32Locals } from "./int32-locals.js"; +import { findIntegerViews } from "./integer-views.js"; +import { + mangleArgPack, + mangleAsyncAdoptCall, + mangleAsyncAwaitCall, + mangleAsyncSpawn, + mangleBorrowedFunction, + mangleClassStruct, + mangleFunction, + mangleGenDrop, + mangleGenSourceClose, + mangleGenSpawn, + mangleGlobal, + mangleLocal, + mangleTrampoline, +} from "../mangle.js"; +import { InternalCompilerError } from "../../errors.js"; +import { classEnvironmentIndex } from "./classes.js"; +import { FN_ATTRS, boxNewCall, releaseSym, traceArg, vAdapters } from "./shapes.js"; +import type { LlValue } from "./expr-context.js"; +import { INT32_ABI_SUFFIX, SCALAR_UNION_RESULT } from "./int32-abi.js"; +import { f64Lit } from "./common.js"; +import { RESULT_ENTRY_PREFIX } from "./record-results.js"; +import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js"; + +export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { + const B = new BlockBuilder(); + emitter.B = B; + emitter.currentFunctionName = fn.name; + // Fiber switches restore the active exception cell before control + // returns to a synchronous frame (emitPendingCheck). + B.excCellInvariant = fn.async !== true && fn.generator === undefined; + emitter.debugScope = emitter.debug?.function(fn) ?? null; + B.debugLocation = emitter.debug?.location(fn.loc, emitter.debugScope) ?? null; + emitter.frames = []; + emitter.scopes = []; + emitter.unwindCleanups.clear(); + emitter.jumpTargets = []; + emitter.currentLocals = new Map(fn.locals.map((l) => [l.id, l])); + emitter.lazyCaptureBoxes.clear(); + emitter.lazyStackBoxes.clear(); + // An unchanged captured parameter keeps its value in a plain slot; the + // binding is boxed only for closures (captureBox). + const lazyParameters = emitter.lazyCaptures.parameters(fn); + for (const id of lazyParameters) { + const { boxed: _boxed, ...plain } = emitter.currentLocals.get(id)!; + emitter.currentLocals.set(id, plain); + emitter.lazyCaptureBoxes.set(id, ""); + } + emitter.currentConstantCallbacks = + emitter.debug === null ? (emitter.constantCallbacks.get(fn.name) ?? new Map()) : new Map(); + // Preserve concrete source bindings for debugger inspection. Suspended + // functions keep the established array lifetime across continuations. + emitter.streamingSplitsEnabled = emitter.debug === null && !fn.async && !fn.generator; + emitter.stackCallbacks.reset(emitter.streamingSplitsEnabled); + emitter.stackCaptures.reset(fn, emitter.streamingSplitsEnabled, lazyParameters); + emitter.privateSplitLocals = emitter.streamingSplitsEnabled + ? findPrivateSplitLocals(fn) + : new Map(); + emitter.storedSplits.clear(); + emitter.splitSpans.clear(); + emitter.stringSlices.clear(); + const initializerBindings = emitter.initializerBindings.get(fn.name) ?? []; + const numericFn = withInitializerBindings(fn, initializerBindings); + emitter.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); + emitter.unaliasedArrays = findUnaliasedLocalArrays(fn); + emitter.borrowedParameters.clear(); + emitter.referenceWrites?.enter(fn); + emitter.reboundOwnerSlots.clear(); + emitter.projectedParameters.clear(); + const projectedParameterIndexes = emitter.callLifetimes.parameters.get(fn.name); + if (projectedParameterIndexes) + for (const index of projectedParameterIndexes) + emitter.projectedParameters.add(fn.params[index]!.localId); + emitter.stableCallBindings = emitter.callLifetimes.bindings.get(fn.name) ?? new Set(); + const borrowedParameterIndexes = emitter.callLifetimes.borrowed.get(fn.name); + if (borrowedParameterIndexes) { + // Rebound walk parameters borrow too, but only unchanged ones may + // serve as stable owners for call arguments and aliases. + for (const index of borrowedParameterIndexes) + if ( + !emitter.walkBorrowsByFunction.get(fn.name)?.has(fn.params[index]!.localId) && + !emitter.reboundByFunction.get(fn.name)?.has(fn.params[index]!.localId) + ) + emitter.borrowedParameters.add(fn.params[index]!.localId); + } + // A generator frame owns its unchanged parameters across suspensions. + const generatorParameters = emitter.callLifetimes.generatorParameters.get(fn.name); + if (generatorParameters !== undefined) + for (const id of generatorParameters) emitter.borrowedParameters.add(id); + emitter.captureIds = new Set( + [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), + ); + emitter.stableCaptureIds = + fn.captures !== undefined ? emitter.lazyCaptures.stableCaptures(fn.name) : new Set(); + emitter.integerLoopBindings.clear(); + emitter.countedLoopsEnabled = emitter.debug === null && !fn.async && !fn.generator; + emitter.speculativeLoopsEnabled = emitter.countedLoopsEnabled; + emitter.speculativeFunction = fn.name; + emitter.speculation = null; + emitter.speculationResume = null; + emitter.finalFieldBorrows = + !fn.async && + !fn.generator && + constructorClass(fn.name) === null && + emitter.finalFields.size > 0; + emitter.byteWindowsEnabled = emitter.countedLoopsEnabled; + emitter.denseLoopsEnabled = emitter.countedLoopsEnabled; + emitter.denseLoop = null; + emitter.denseLoopEntries = + emitter.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array") + ? findInitializedByteLoopBindings(fn) + : new Map(); + emitter.byteWindowEntries = + emitter.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes") + ? findInitializedByteLoopBindings(fn) + : new Map(); + emitter.integerViews.clear(); + emitter.fieldPointerTags.clear(); + emitter.integerArrayBindings.clear(); + emitter.loopArrayBorrows = + emitter.debug === null + ? findLoopArrayBorrows( + fn, + emitter.callLifetimes, + emitter.referenceEffects, + emitter.optionalArrayReads, + emitter.referenceWrites, + emitter.fnByName, + ) + : new Set(); + emitter.localArrayReads = findLocalArrayReads( + fn, + emitter.fnByName, + emitter.unionsById, + emitter.referenceEffects.functions, + emitter.callLifetimes, + emitter.optionalArrayReads, + emitter.loopArrayBorrows, + emitter.debug === null ? emitter.referenceEffects : undefined, + ); + emitter.callArrayReads = findCallArrayReads( + fn, + emitter.optionalArrayReads, + emitter.referenceEffects.functions, + emitter.callLifetimes, + emitter.debug === null ? emitter.referenceEffects : undefined, + ); + emitter.scalarStringSlices = + emitter.debug === null ? findScalarStringSlices(fn, emitter.callLifetimes) : new Map(); + emitter.scratchStringSlices = + emitter.debug === null + ? findScratchStringSlices( + fn, + emitter.callLifetimes, + emitter.scalarStringSlices, + emitter.lentStringParameters, + ) + : new Set(); + emitter.mapReadLifetimes = findMapReadLifetimes( + fn, + emitter.boxedUnionsById, + emitter.callLifetimes, + ); + emitter.readNumberMapWrites = findReadNumberMapWrites(fn); + emitter.mapEntryFusion = emitter.debug === null ? findMapEntryFusions(fn) : null; + emitter.localStackUnions = findLocalStackUnions( + fn, + emitter.callLifetimes, + emitter.boxedUnionsById, + ); + emitter.localStackObjects = emitter.stackObjects + ? findLocalStackObjects(emitter, fn, emitter.callLifetimes) + : new Map(); + emitter.reassignedStackObjects = emitter.stackObjects + ? findReassignedStackObjects(emitter, fn, emitter.callLifetimes) + : new Set(); + emitter.stackObjectSlots = new Map(); + emitter.stackObjectFieldPtrs = new Map(); + emitter.localUnionStorageProofs = findLocalUnionStorage( + fn, + emitter.callLifetimes, + emitter.unionSources, + ); + emitter.localUnionStorage = new Map(); + emitter.walkBorrows = emitter.walkBorrowsByFunction.get(fn.name) ?? new Set(); + const slotFacts = emitter.int32Slots.facts(fn.name); + emitter.integerRanges = analyzeIntegerRanges(numericFn, slotFacts); + emitter.int32Elements = slotFacts.elements ?? null; + emitter.bytesBounds = findBytesBounds(numericFn, emitter.integerRanges); + // Debug builds keep every number local a double for the debugger's + // variable view. Lazily boxed captured parameters keep their double, + // which the box is created from, and so do plain copies of a capture or + // such a parameter (copyStableCaptures): the source already holds the + // double, which an i32 copy would convert and widen again on every call. + // Initializer bindings proven int32 move into an i32 local of their + // initializer: no other code observes the global, so the loop-carried + // value never round-trips through the double slot. + const initializerLocals = initializerLocalCandidates(initializerBindings); + const int32Locals = + emitter.debug === null + ? findInt32Locals( + withInitializerLocals(fn, initializerLocals), + emitter.integerRanges, + emitter.int32Slots.facts(fn.name), + new Set([...emitter.lazyCaptureBoxes.keys(), ...emitter.doubleCopies(fn)]), + ) + : null; + emitter.int32Locals = int32Locals?.locals ?? new Map(); + emitter.int32LocalWrites = int32Locals?.values ?? new Set(); + const localizedGlobals = new Set(); + for (const global of initializerLocals) { + if (!emitter.int32Locals.has(global.id)) continue; + localizedGlobals.add(global.id); + emitter.currentLocals.set(global.id, { + id: global.id, + name: global.name, + type: global.type, + mutable: global.mutable, + }); + } + emitter.chainSlots.clear(); + emitter.finallyStack = []; + emitter.tryStack = []; + emitter.loopDepth = 0; + emitter.currentReturnType = fn.returnType; + const int32Abi = emitter.int32Abis.get(fn.name); + emitter.currentInt32Result = int32Abi?.result === true; + emitter.currentUnionResult = int32Abi?.union === true; + emitter.currentGenerator = fn.generator ?? null; + emitter.currentAsyncGenerator = fn.async === true && fn.generator !== undefined; + emitter.currentBorrowedReturn = emitter.borrowedReturns.has(fn.name); + emitter.lastUseMoves.clear(); + emitter.varRefCounts = null; + emitter.currentFunctionBody = fn.body; + emitter.inConstructor = emitter.constructorFunctions.has(fn.name); + emitter.currentCoro = null; + emitter.logArgSlots = 0; + + if (emitter.coroLowered(fn)) { + emitter.usesCoroutines = true; + emitter.declare(`declare token @llvm.coro.id(i32, ptr, ptr, ptr)`); + emitter.declare(`declare ${emitter.sizeType} @llvm.coro.size.${emitter.sizeType}()`); + emitter.declare(`declare ptr @llvm.coro.begin(token, ptr)`); + emitter.declare(`declare token @llvm.coro.save(ptr)`); + emitter.declare(`declare i8 @llvm.coro.suspend(token, i1)`); + emitter.declare(`declare ptr @llvm.coro.free(token, ptr)`); + emitter.declare(`declare void @llvm.coro.end(ptr, i1, token)`); + // Native frames come from the runtime's small-block allocator. + const frameAlloc = emitter.wasi ? "malloc" : "scr_rt_calloc"; + emitter.declare(`declare ptr @${frameAlloc}(${emitter.sizeType})`); + emitter.declare(`declare void @${emitter.wasi ? "free" : "scr_rt_free"}(ptr)`); + const started = emitter.wasi ? "scr_wasi_coro_started" : "scr_coro_started"; + emitter.declare(`declare void @${started}(ptr)`); + emitter.declare(`declare ptr @scr_fiber_self()`); + const id = B.tmp(); + const size = B.tmp(); + const mem = B.tmp(); + const handle = B.tmp(); + const self = B.tmp(); + B.line(`${id} = call token @llvm.coro.id(i32 0, ptr null, ptr null, ptr null)`); + B.line(`${size} = call ${emitter.sizeType} @llvm.coro.size.${emitter.sizeType}()`); + B.line(`${mem} = call ptr @${frameAlloc}(${emitter.sizeType} ${size})`); + B.line(`${handle} = call ptr @llvm.coro.begin(token ${id}, ptr ${mem})`); + B.line(`call void @${started}(ptr ${handle})`); + B.line(`${self} = call ptr @scr_fiber_self()`); + // A native synchronous body owns its generator for the whole run (the + // ScrGen owns the frame), so its OUT slot address is fixed. + let out: string | null = null; + const yieldKind = fn.generator?.yieldT.kind; + if ( + !emitter.wasi && + fn.async !== true && + (yieldKind === "f64" || yieldKind === "date" || yieldKind === "bool") + ) { + emitter.declare(`declare ptr @scr_gen_of_fiber(ptr)`); + const gen = B.tmp(); + B.line(`${gen} = call ptr @scr_gen_of_fiber(ptr ${self})`); + out = emitter.genOutSlot(gen); + } + emitter.currentCoro = { + kind: fn.generator !== undefined ? "generator" : "async", + id, + handle, + self, + finalLabel: B.newLabel("coro.final"), + cleanupLabel: B.newLabel("coro.cleanup"), + suspendLabel: B.newLabel("coro.suspend"), + out, + }; + } + + const paramIds = new Set(fn.params.map((p) => p.localId)); + if (emitter.debug === null) { + for (const id of findIntegerViews(numericFn)) { + // An i32 slot is its own view. + if (emitter.int32Locals.has(id)) continue; + const slot = B.slot(); + B.entryAllocas.push( + `${slot} = alloca i32 ; integer view ${emitter.numericLocals.get(id)!.name}`, + ); + emitter.integerViews.set(id, slot); + } + } + for (const global of initializerBindings) { + if (!emitter.integerViews.has(global.id)) continue; + const value = B.tmp(); + B.line(`${value} = load double, ptr @${mangleGlobal(global.id)}`); + emitter.storeIntegerView(global.id, { name: value, type: global.type }); + } + // A number global only its guarded, synchronous initializer reads or + // writes (findInitializerBindings) is invisible to every other function, + // and reentry returns before reaching it, so a local slot can stand in + // for it: LLVM keeps it in a register across calls and loops that would + // otherwise force a store and reload per iteration. The global's + // storage is never read again, so nothing writes the slot back. Bindings + // proven int32 already live in an i32 local (localizedGlobals above); + // this covers the remaining doubles. + emitter.privateGlobalSlots.clear(); + if (emitter.debug === null && emitter.currentCoro === null && !fn.async && !fn.generator) { + const conditional = initializerLocals.length ? conditionalLoopWrites(fn.body) : null; + for (const global of initializerLocals) { + if (localizedGlobals.has(global.id)) continue; + // A conditional update in a loop stays a memory update (see + // conditionalLoopWrites). + if (conditional?.has(global.id)) continue; + const slot = B.slot(); + const value = B.tmp(); + B.entryAllocas.push(`${slot} = alloca double ; module binding ${global.name}`); + B.line(`${value} = load double, ptr @${mangleGlobal(global.id)}`); + B.line(`store double ${value}, ptr ${slot}`); + emitter.privateGlobalSlots.set(global.id, slot); + } + } + for (const local of emitter.currentLocals.values()) { + // Boxed locals' slots hold their capture BOX (a ptr); captured + // (env-borrowed) locals bind the incoming box below. A caught-typed + // local is a catch binding: its slot holds the ScrCaught snapshot + // box the catch prologue takes (scr_exc_take). + const slotTy = + local.boxed || emitter.captureIds.has(local.id) || local.type.kind === "caught" + ? "ptr" + : emitter.int32Locals.has(local.id) + ? "i32" + : emitter.llType(local.type); + B.entryAllocas.push(`%${mangleLocal(local.id)} = alloca ${slotTy} ; ${local.name}`); + emitter.emitDebugLocal( + local, + `%${mangleLocal(local.id)}`, + fn.params.findIndex((p) => p.localId === local.id) + 1, + emitter.captureIds.has(local.id), + ); + // A localized initializer binding starts at its global's zero value. + if (localizedGlobals.has(local.id)) B.line(`store i32 0, ptr %${mangleLocal(local.id)}`); + // Refcounted/boxed locals start NULL (the C prologue's `= NULL`): + // scope-exit releases run whether or not an assign ever did. + if (paramIds.has(local.id) || emitter.captureIds.has(local.id)) continue; + if (local.boxed || isRefCounted(local.type)) { + B.line(`store ptr null, ptr %${mangleLocal(local.id)}`); + } + } + // A body holding thousands of capture boxes compiles unoptimized, its + // box pointers kept in their slots (see boxSlotCount). + const unoptimized = emitter.currentCoro === null && boxSlotCount(fn) >= UNOPTIMIZED_BOX_SLOTS; + if (unoptimized) { + const slots = new Set(); + for (const local of emitter.currentLocals.values()) + if (local.boxed || emitter.captureIds.has(local.id)) slots.add(`%${mangleLocal(local.id)}`); + B.volatileSlots = slots; + } + // Captured bindings come in through the environment — borrowed for the + // whole call (the closure owns them): bound here, never released here. + (fn.captures ?? []).forEach((c, i) => { + const caps = B.tmp(); + const p = B.tmp(); + const box = B.tmp(); + B.line(`${caps} = getelementptr inbounds %ScrClosure, ptr %sc_env, i64 1 ; caps`); + B.line(`${p} = getelementptr inbounds ptr, ptr ${caps}, ${emitter.sizeType} ${i} ; caps[${i}]`); + B.line(`${box} = load ptr, ptr ${p}`); + B.line(`store ptr ${box}, ptr %${mangleLocal(c.localId)} ; captured ${c.name}`); + }); + (fn.classCaptures ?? []).forEach((c) => { + const self = fn.params[0]!; + if (self.type.kind !== "object") + throw new InternalCompilerError("class captures require an instance receiver"); + const classSlot = B.tmp(); + const classValue = B.tmp(); + const caps = B.tmp(); + const slot = B.tmp(); + const box = B.tmp(); + B.line( + `${classSlot} = getelementptr inbounds %${mangleClassStruct(self.type.className)}, ptr %p_${mangleLocal(self.localId)}, i64 0, i32 ${classEnvironmentIndex(emitter.classMeta.get(self.type.className)!)}`, + ); + B.line(`${classValue} = load ptr, ptr ${classSlot}`); + B.line(`${caps} = getelementptr inbounds %ScrClassObj, ptr ${classValue}, i64 1`); + B.line(`${slot} = getelementptr inbounds ptr, ptr ${caps}, ${emitter.sizeType} ${c.slot}`); + B.line(`${box} = load ptr, ptr ${slot}`); + B.line(`store ptr ${box}, ptr %${mangleLocal(c.localId)} ; class capture ${c.name}`); + }); + // Params spill into their slots; the function scope owns refcounted + // params (callees own their params — callers passed +1). Boxed params + // allocate the shared binding and move the raw value in. + const fnScope: LlScopeEntry[] = []; + for (const id of emitter.lazyCaptureBoxes.keys()) { + const cache = B.slot(); + B.entryAllocas.push(`${cache} = alloca ptr ; lazy box ${emitter.currentLocals.get(id)!.name}`); + B.line(`store ptr null, ptr ${cache}`); + fnScope.push({ + slot: cache, + type: emitter.currentLocals.get(id)!.type, + boxed: true, + lazy: true, + }); + emitter.lazyCaptureBoxes.set(id, cache); + // Environments that finish during the call can share an immortal + // frame box. Its payload owner is released with the function scope. + const stack = emitter.stackCaptures.emit(emitter, id, emitter.currentLocals.get(id)!.type); + if (stack) { + emitter.lazyStackBoxes.set(id, stack.box); + if (stack.owner) fnScope.push(stack.owner); + } + } + const borrowed = emitter.callLifetimes.borrowed.get(fn.name); + for (const [index, p] of fn.params.entries()) { + const local = emitter.currentLocals.get(p.localId)!; + const slot = `%${mangleLocal(p.localId)}`; + if (local.boxed) { + const box = B.tmp(); + B.line(`${box} = ${boxNewCall(emitter.shapeHost, p.type)} ; ${p.name} (boxed param)`); + emitter.boxSet(box, p.type, `%p_${mangleLocal(p.localId)}`); + B.line(`store ptr ${box}, ptr ${slot}`); + fnScope.push({ slot, type: p.type, boxed: true }); + continue; + } + const incoming: LlValue = { name: `%p_${mangleLocal(p.localId)}`, type: p.type }; + if (int32Abi?.params[index]) { + const bits = `%p_${mangleLocal(p.localId)}${INT32_ABI_SUFFIX}`; + incoming.uint32 = bits; + incoming.integer = { name: bits, type: "i32", signed: true, range: INT32_RANGE }; + } + if (emitter.storeInt32Local(p.localId, incoming)) continue; + B.line(`store ${emitter.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); + emitter.storeIntegerView(p.localId, incoming); + if (isRefCounted(p.type) && !borrowed?.has(index)) { + fnScope.push({ slot, type: p.type }); + emitter.noteParameterMove(fn, p.localId, slot); + } + if (emitter.reboundByFunction.get(fn.name)?.has(p.localId) && borrowed?.has(index)) { + // The caller's borrow stays in the parameter slot; values the body + // assigns are owned here until rebinding or exit releases them. + const owner = `${slot}.owner`; + B.entryAllocas.push(`${owner} = alloca ptr`); + B.line(`store ptr null, ptr ${owner}`); + fnScope.push({ slot: owner, type: p.type }); + emitter.reboundOwnerSlots.set(p.localId, owner); + } + } + emitter.scopes.push(fnScope); + // Stackful native fibers and native coroutine frames retain these frames + // while suspended. The active exception context owns the chain, so + // concurrent fibers isolate it. + if ( + emitter.stackTraces && + !emitter.wasi && + fn.sourceName !== undefined && + !emitter.unframedFunctions.has(fn.name) + ) { + B.entryAllocas.push(`%source_frame = alloca { ptr, ptr }`); + const frame = emitter.cstr(` at ${fn.sourceName} (${fn.loc.file})`); + if ( + B.excCellInvariant && + emitter.mod.lib === undefined && + emitter.mod.workers !== true && + emitter.sizeType === "i64" + ) { + // Executables link the active-cell pointer as a plain global, and a + // synchronous body sees one cell from entry to every return: push + // and pop the frame inline (ScrExcCell.stack is at byte 56 on + // 64-bit targets) instead of calling the runtime twice per call. + emitter.declare(`@scr_exc_active = external global ptr`); + B.line(`%source_frame.cell = load ptr, ptr @scr_exc_active`); + B.line(`%source_frame.chain = getelementptr inbounds i8, ptr %source_frame.cell, i64 56`); + B.line(`%source_frame.previous = load ptr, ptr %source_frame.chain`); + B.line(`store ptr %source_frame.previous, ptr %source_frame`); + B.line(`%source_frame.text = getelementptr inbounds i8, ptr %source_frame, i64 8`); + B.line(`store ptr ${frame}, ptr %source_frame.text`); + B.line(`store ptr %source_frame, ptr %source_frame.chain`); + B.returnEpilogue = `store ptr %source_frame.previous, ptr %source_frame.chain`; + } else { + emitter.declare(`declare void @scr_stack_enter(ptr, ptr)`); + emitter.declare(`declare void @scr_stack_leave(ptr)`); + B.line(`call void @scr_stack_enter(ptr %source_frame, ptr ${frame})`); + B.returnEpilogue = `call void @scr_stack_leave(ptr %source_frame)`; + } + } + if (emitter.stackChecks?.has(fn.name)) emitter.emitStackCheck(); + if (emitter.workerEntryPolls?.has(fn.name)) emitter.emitPendingCheck(undefined, true); + emitter.emitStmts(fn.body, true); + // Implicit exit of a void function: release the function scope unless + // the body already terminated its final block (return, or a throw + // whose unwind released everything down to depth 0). + if (fn.returnType.kind === "void" && !B.isTerminated()) { + emitter.releaseScope(emitter.scopes[0]!); + // Completing by falling off the end has nothing to await. + if (emitter.currentAsyncGenerator) emitter.emitAsyncGenSettled(); + if (emitter.currentCoro !== null) { + emitter.emitWasiFulfill(null); + B.terminate(`br label %${emitter.currentCoro.finalLabel}`); + } else { + B.terminate("ret void"); + } + } + emitter.scopes.pop(); + + const coro = emitter.currentCoro; + if (coro !== null) { + B.startBlock(coro.finalLabel); + // Every exit of a coroutine body joins here: its source frame (part + // of the coroutine frame) leaves the generator's own chain. + const epilogue = B.returnEpilogue; + B.returnEpilogue = null; + if (epilogue !== null) B.line(epilogue); + if (fn.captures !== undefined) { + emitter.declare(`declare void @scr_closure_release(ptr)`); + B.line(`call void @scr_closure_release(ptr %sc_env)`); + } + if (coro.kind === "generator") { + const finish = emitter.wasi ? "scr_wasi_gen_finish" : "scr_gen_coro_finish"; + emitter.declare(`declare void @${finish}(ptr)`); + B.line(`call void @${finish}(ptr ${coro.self})`); + } else { + const finish = emitter.wasi ? "scr_wasi_async_finish" : "scr_coro_async_finish"; + emitter.declare(`declare void @${finish}(ptr)`); + B.line(`call void @${finish}(ptr ${coro.self})`); + } + const finalState = B.tmp(); + B.line(`${finalState} = call i8 @llvm.coro.suspend(token none, i1 true)`); + B.terminate( + `switch i8 ${finalState}, label %${coro.suspendLabel} [ i8 1, label %${coro.cleanupLabel} ]`, + ); + B.startBlock(coro.cleanupLabel); + const frame = B.tmp(); + B.line(`${frame} = call ptr @llvm.coro.free(token ${coro.id}, ptr ${coro.handle})`); + B.line(`call void @${emitter.wasi ? "free" : "scr_rt_free"}(ptr ${frame})`); + B.br(coro.suspendLabel); + B.startBlock(coro.suspendLabel); + B.line(`call void @llvm.coro.end(ptr ${coro.handle}, i1 false, token none)`); + const ret = emitter.llType(fn.returnType); + if (ret === "void") B.terminate(`ret void`); + else if (ret === "double") B.terminate(`ret double ${f64Lit(0)}`); + else if (ret === "i1") B.terminate(`ret i1 false`); + else B.terminate(`ret ptr null`); + } + + // Shared blocks have multiple source locations. Keep the location on + // each incoming exception check instead of assigning one to the cleanup. + B.debugLocation = null; + for (const cleanup of emitter.unwindCleanups.values()) { + B.startBlock(cleanup.label); + emitter.releaseScope(cleanup.entries); + B.terminate(cleanup.terminator); + } + + if (emitter.logArgSlots > 0) { + B.entryAllocas.push(`%logargs = alloca [${emitter.logArgSlots} x %ScrLogArg]`); + } + const params = fn.params.map((p, index) => + int32Abi?.params[index] + ? `i32 %p_${mangleLocal(p.localId)}${INT32_ABI_SUFFIX}` + : `${emitter.llType(p.type)} %p_${mangleLocal(p.localId)}`, + ); + // The i32 entry widens its i32 parameters once; the proof makes the + // double exact, so readers of the incoming number see the same value. + if (int32Abi) + fn.params.forEach((p, index) => { + if (!int32Abi.params[index]) return; + const name = `%p_${mangleLocal(p.localId)}`; + B.entryAllocas.push(`${name} = sitofp i32 ${name}${INT32_ABI_SUFFIX} to double`); + }); + // Lifted functions receive their closure first (the callValue ABI). + if (fn.captures !== undefined) params.unshift("ptr %sc_env"); + const ret = int32Abi?.result + ? "i32" + : int32Abi?.union + ? SCALAR_UNION_RESULT + : emitter.llType(fn.returnType); + let attrs = coro !== null ? "#1" : FN_ATTRS; + // The shared indexed-read helper (lowerSafeIndexRead) is a dense bounds + // test, a state test and a load behind cold runtime fallbacks, called + // from every `a[i]` of one element type. Without the hint LLVM keeps the + // hot instances out of line once they have many callers: every read pays + // a call, and an integer index converts to a double and back across it. + if (fn.name.startsWith("%arr.idxOr.")) attrs = `inlinehint ${attrs}`; + // Interface dispatch is a few key tests and direct calls ahead of the + // checked dynamic call. Keeping that fallback out of line leaves the + // tests small enough to inline at each call site. + if (fn.name.startsWith("%dyn.class.protocol.fallback.")) attrs = `noinline ${attrs}`; + else if (fn.name.startsWith("%dyn.class.protocol.")) attrs = `inlinehint ${attrs}`; + if (fn.name.startsWith(RESULT_ENTRY_PREFIX)) attrs = `inlinehint ${attrs}`; + if (unoptimized && attrs === FN_ATTRS) attrs = `noinline optnone ${attrs}`; + const debug = emitter.debugScope === null ? "" : ` !dbg ${emitter.debugScope}`; + const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name); + emitter.privateGlobalSlots.clear(); + let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`; + if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi); + if (emitter.integerClones.members.has(fn.name)) { + body += "\n" + emitIntegerClone(fn, emitter, emitter.stackChecks?.has(fn.name) === true); + if (emitter.integerClones.dispatched.has(fn.name)) + body += + "\n" + + emitIntegerDispatcher( + fn, + emitter, + `${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}`, + int32Abi?.params ?? [], + int32Abi?.result === true, + ); + } + if (!borrowed) return body; + const slot = emitter.implSlotBorrowed(fn.name); + const virtual = + slot.size > 0 && slot.size !== borrowed.size + ? "\n" + emitter.emitOwnedCallAdapter(fn, borrowed, slot) + : ""; + return body + "\n" + emitter.emitOwnedCallAdapter(fn, borrowed) + virtual; +} + +/** Per-generator-function machinery — the async scaffolding's lazy + * sibling (async.ts's emitGenScaffolding): the same argument pack, + * a fiber trampoline whose epilogue stores the COMPLETION value (or + * consumes the GENRET sentinel, promoting the parked .return value), a + * spawn wrapper that only ALLOCATES the suspended fiber, and the + * never-started teardown that drops the packed (+1) arguments. */ +export function emitGenScaffolding(emitter: LlEmitter): string[] { + const out: string[] = []; + for (const fn of emitter.mod.functions) { + if (fn.generator === undefined) continue; + const { definitions, pack, lifted, fieldTys, ret, tr, spawnParams, argPackLines } = + emitter.emitArgPackAndTrampolinePrologue(fn); + for (const line of definitions) out.push(line); + emitter.declare(`declare zeroext i1 @scr_exc_genret_pending()`); + emitter.declare(`declare void @scr_exc_clear()`); + emitter.declare(`declare ptr @scr_gen_of_fiber(ptr)`); + emitter.declare(`declare void @scr_gen_ret_to_out(ptr)`); + emitter.declare(`declare ptr @scr_gen_new(ptr, ptr, ptr)`); + emitter.needOom(); + const coroBody = emitter.coroLowered(fn); + if (lifted && !coroBody) { + emitter.declare(`declare void @scr_closure_release(ptr)`); + } + // Normal completion stores the (typed) return value; void completes + // with the NONE slot — JS's undefined done-value. A GENRET unwind + // consumes the sentinel and promotes the parked .return value; a + // real exception stays pending (the consumer-side resume moves it). + if (coroBody) { + tr.push(` ret void`, `}`, ``); + } else { + tr.push( + ` %g = call ptr @scr_gen_of_fiber(ptr %self)`, + ...emitter.pendingTestLines("%pend"), + ` br i1 %pend, label %thrown, label %clean`, + `clean:`, + ); + switch (ret.kind) { + case "void": + tr.push(` br label %done ; void body: the done value is undefined (NONE)`); + break; + case "f64": + case "date": + emitter.declare(`declare void @scr_gen_out_f64(ptr, double)`); + tr.push(` call void @scr_gen_out_f64(ptr %g, double %r)`, ` br label %done`); + break; + case "bool": + emitter.declare(`declare void @scr_gen_out_bool(ptr, i1 zeroext)`); + tr.push(` call void @scr_gen_out_bool(ptr %g, i1 %r)`, ` br label %done`); + break; + default: { + const v = vAdapters(emitter.shapeHost, ret); + emitter.declare(`declare void @scr_gen_out_ref(ptr, ptr, ptr)`); + tr.push( + ` call void @scr_gen_out_ref(ptr %g, ptr %r, ptr ${v.release})`, + ` br label %done`, + ); + } + } + tr.push( + `thrown:`, + ` %genret = call zeroext i1 @scr_exc_genret_pending()`, + ` br i1 %genret, label %promote, label %dropdummy`, + `promote:`, + ` call void @scr_exc_clear()`, + ` call void @scr_gen_ret_to_out(ptr %g)`, + ` br label %dropdummy`, + `dropdummy:`, + ); + if (ret.kind !== "void" && isRefCounted(ret)) { + tr.push( + ` call void ${releaseSym(emitter.shapeHost, ret)}(ptr %r) ; unwound: the never-read dummy`, + ); + } + tr.push(` br label %done`, `done:`, ` ret void`, `}`, ``); + } + for (const line of tr) out.push(line); + + let settleAsync: string | null = null; + if (fn.async) { + emitter.declare(`declare ptr @scr_async_gen_new(ptr, ptr, ptr, ptr)`); + const genT: IrType & { kind: "generator" } = { + kind: "generator", + async: true, + yieldT: fn.generator.yieldT, + retT: ret, + nextT: fn.generator.nextT, + }; + const resultT = fn.generator.resultType; + const build = emitter.genResultThunkFor(genT, resultT); + settleAsync = `${build}_async`; + const settleKey = `ags:${typeKey(genT)}`; + if (!emitter.resolveThunks.has(settleKey)) { + emitter.resolveThunks.set(settleKey, settleAsync); + const adapters = vAdapters(emitter.shapeHost, resultT); + emitter.resolveThunkDefs.push( + `define internal void @${settleAsync}(ptr %g, ptr %p) ${FN_ATTRS} {`, + `entry:`, + ` %r = call ptr @${build}(ptr %g)`, + ` call void @scr_promise_fulfill_ref(ptr %p, ptr %r, ptr ${adapters.retain}, ptr ${adapters.release}, ptr ${traceArg(emitter.shapeHost, resultT)})`, + ` ret void`, + `}`, + ``, + ); + } + emitter.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); + } + + // The never-started teardown: drop the packed (+1) arguments. + const dr: string[] = [ + `define internal void @${mangleGenDrop(fn.name)}(ptr %ap) ${FN_ATTRS} {`, + `entry:`, + ]; + fieldTys.forEach((ty, i) => { + const pType = lifted && i === 0 ? null : fn.params[lifted ? i - 1 : i]!.type; + const refcounted = pType === null || isRefCounted(pType); + if (!refcounted) return; + dr.push( + ` %dp${i} = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${i}`, + ` %dv${i} = load ptr, ptr %dp${i}`, + ); + if (pType === null) { + emitter.declare(`declare void @scr_closure_release(ptr)`); + dr.push(` call void @scr_closure_release(ptr %dv${i})`); + } else { + dr.push(` call void ${releaseSym(emitter.shapeHost, pType)}(ptr %dv${i})`); + } + }); + dr.push(` call void @free(ptr %ap)`, ` ret void`, `}`, ``); + for (const line of dr) out.push(line); + + // An iterator helper's return() before its first next() closes the + // source generator in its first argument slot. + const closesSource = fn.generator.closesSource === true; + if (fn.generator.tag !== undefined) emitter.declare(`declare void @scr_gen_set_tag(ptr, ptr)`); + if (fn.generator.inheritsTag === true) + emitter.declare(`declare void @scr_gen_copy_tag(ptr, ptr)`); + if (closesSource) { + const field = lifted ? 1 : 0; + emitter.declare(`declare void @scr_gen_close(ptr)`); + emitter.declare(`declare void @scr_gen_set_unstarted_return(ptr, ptr)`); + out.push( + `define internal void @${mangleGenSourceClose(fn.name)}(ptr %ap) ${FN_ATTRS} {`, + `entry:`, + ` %sp = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${field}`, + ` %sv = load ptr, ptr %sp`, + ` call void @scr_gen_close(ptr %sv)`, + ` ret void`, + `}`, + ``, + ); + } + + // Spawn wrapper: pack the args (+1 moves in), allocate the + // SUSPENDED fiber — nothing runs until the first .next(). + if (lifted) { + emitter.declare(`declare ptr @scr_closure_retain_v(ptr)`); + } + const sp: string[] = [ + `define internal ptr @${mangleGenSpawn(fn.name)}(${spawnParams.join(", ")}) ${FN_ATTRS} { ; gen spawn ${fn.name}`, + `entry:`, + ...argPackLines, + ]; + const genNew = coroBody && !emitter.wasi ? "scr_gen_new_stackless" : "scr_gen_new"; + if (genNew !== "scr_gen_new") emitter.declare(`declare ptr @${genNew}(ptr, ptr, ptr)`); + sp.push( + settleAsync === null + ? ` %gg = call ptr @${genNew}(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)})` + : ` %gg = call ptr @scr_async_gen_new(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)}, ptr @${settleAsync})`, + ...(fn.generator.tag !== undefined + ? [` call void @scr_gen_set_tag(ptr %gg, ptr ${emitter.cstr(fn.generator.tag)})`] + : []), + ...(fn.generator.inheritsTag === true + ? [` call void @scr_gen_copy_tag(ptr %gg, ptr %a${lifted ? 1 : 0})`] + : []), + ...(closesSource + ? [ + ` call void @scr_gen_set_unstarted_return(ptr %gg, ptr @${mangleGenSourceClose(fn.name)})`, + ] + : []), + ` ret ptr %gg`, + `}`, + ``, + ); + for (const line of sp) out.push(line); + } + return out; +} + +/** Per-async-function machinery — async.ts's scaffolding, .ll + * flavored: an argument-pack struct type, a fiber trampoline (unpacks, + * frees the pack, runs the ordinary compiled body, settles the + * promise — fulfilling on clean return, leaving a pending exception + * for the runtime to reject with), and a spawn wrapper call sites and + * closures enter through (packs the args +1, scr_async_spawn runs the + * fiber eagerly to its first suspension and returns the promise). */ +export function emitAsyncScaffolding(emitter: LlEmitter): string[] { + const out: string[] = []; + if (emitter.wasi) { + emitter.declare(`declare void @llvm.coro.resume(ptr)`); + emitter.declare(`declare void @llvm.coro.destroy(ptr)`); + out.push( + `define void @scr_wasi_coro_resume(ptr %handle) ${FN_ATTRS} {`, + `entry:`, + ` call void @llvm.coro.resume(ptr %handle)`, + ` ret void`, + `}`, + `define void @scr_wasi_coro_destroy(ptr %handle) ${FN_ATTRS} {`, + `entry:`, + ` call void @llvm.coro.destroy(ptr %handle)`, + ` ret void`, + `}`, + ``, + ); + } + const fiberless = emitter.fiberlessAsyncFunctions(); + for (const fn of emitter.mod.functions) { + if (fn.async !== true || fn.generator !== undefined) continue; + if (fiberless.has(fn.name) && emitter.awaitCallees.has(fn.name)) { + for (const line of emitter.emitAsyncAwaitCall(fn, null)) out.push(line); + } + if (fiberless.has(fn.name) && emitter.adoptCallees.has(fn.name)) { + for (const line of emitter.emitAsyncAwaitCall(fn, null, true)) out.push(line); + } + if (emitter.inlineAwaitCallees.has(fn.name)) { + for (const line of emitter.emitAsyncInlineAwaitCall(fn)) out.push(line); + } + if (fiberless.has(fn.name)) { + const fieldTys = [ + ...(fn.captures !== undefined ? ["ptr"] : []), + ...fn.params.map((p) => emitter.llType(p.type)), + ]; + const params = fieldTys.map((ty, i) => `${ty} %a${i}`); + for (const line of emitter.emitFiberlessAsyncSpawn(fn, params)) out.push(line); + continue; + } + const { definitions, ret, tr, spawnParams, argPackLines } = + emitter.emitArgPackAndTrampolinePrologue(fn); + for (const line of definitions) out.push(line); + if (emitter.awaitCallees.has(fn.name)) { + for (const line of emitter.emitAsyncAwaitCall(fn, argPackLines)) out.push(line); + } + if (emitter.adoptCallees.has(fn.name)) { + for (const line of emitter.emitAsyncAwaitCall(fn, argPackLines, true)) out.push(line); + } + const stackless = emitter.stacklessAsync.has(fn.name); + const spawn = stackless ? "scr_async_spawn_stackless" : "scr_async_spawn"; + emitter.declare(`declare ptr @scr_fiber_promise(ptr)`); + emitter.declare(`declare ptr @${spawn}(ptr, ptr)`); + emitter.needOom(); + if (emitter.wasi || stackless) { + // The coroutine body settles its own promise and owns the retained + // lifted environment until final suspension. Its initial call + // returns here at the first suspend (or final suspend). + tr.push(` ret void`, `}`, ``); + } else { + if (fn.captures !== undefined) { + emitter.declare(`declare void @scr_closure_release(ptr)`); + } + tr.push( + ...emitter.pendingTestLines("%pend"), + ` br i1 %pend, label %thrown, label %clean`, + `clean:`, + ` %pr = call ptr @scr_fiber_promise(ptr %self)`, + ); + for (const line of emitter.asyncFulfillLines(ret, "%pr")) tr.push(line); + tr.push(` ret void`, `thrown:`); + if (ret.kind !== "void" && isRefCounted(ret)) { + // An escaping throw means %r is the never-read dummy (NULL). + tr.push(` call void ${releaseSym(emitter.shapeHost, ret)}(ptr %r)`); + } + tr.push(` ret void`, `}`, ``); + } + for (const line of tr) out.push(line); + + // Spawn wrapper: pack the args (+1 moves in), spawn the fiber. + const cache = fn.asyncCacheGlobal !== undefined ? mangleGlobal(fn.asyncCacheGlobal) : null; + const cycleCache = + fn.asyncCycleCacheGlobal !== undefined ? mangleGlobal(fn.asyncCycleCacheGlobal) : null; + if (cache !== null || cycleCache !== null) { + emitter.declare(`declare ptr @scr_promise_retain_v(ptr)`); + emitter.declare(`declare void @scr_promise_release(ptr)`); + } + if (cache !== null) { + emitter.declare(`declare void @scr_promise_mark_handled(ptr)`); + } + if (fn.captures !== undefined) { + emitter.declare(`declare ptr @scr_closure_retain_v(ptr)`); + } + const sp: string[] = [ + `define internal ptr @${mangleAsyncSpawn(fn.name)}(${spawnParams.join(", ")}) ${FN_ATTRS} { ; spawn ${fn.name}`, + `entry:`, + ...(cache !== null + ? [ + ` %cached = load ptr, ptr @${cache}`, + ` %cache_hit = icmp ne ptr %cached, null`, + ` br i1 %cache_hit, label %cached_return, label %cache_miss`, + `cached_return:`, + ` %cached_owned = call ptr @scr_promise_retain_v(ptr %cached)`, + ` ret ptr %cached_owned`, + `cache_miss:`, + ] + : []), + ...argPackLines, + ]; + sp.push( + ` %p = call ptr @${spawn}(ptr @${mangleTrampoline(fn.name)}, ptr %ap)`, + ...(cache !== null + ? [ + // The module loader owns this evaluation promise + // immediately. A later sibling can throw before the + // aggregate dependency wait is built, but this rejection + // must never become an unrelated unhandled rejection. + ` call void @scr_promise_mark_handled(ptr %p)`, + ] + : []), + ...(cache !== null + ? [ + ` %cache_owned = call ptr @scr_promise_retain_v(ptr %p)`, + // The eager spawn may have re-entered this guarded module + // through an admitted async cycle and installed a temporary + // cache entry. Drop that owned slot before replacing it + // with the outer evaluation promise. + ` %replaced_cache = load ptr, ptr @${cache}`, + ` call void @scr_promise_release(ptr %replaced_cache)`, + ` store ptr %cache_owned, ptr @${cache}`, + ] + : []), + ...(cycleCache !== null + ? [ + // Eager recursive spawns publish from the inside out. The + // runtime-requested outermost member writes last and is the + // SCC's actual evaluation root. + ` %cycle_cache_owned = call ptr @scr_promise_retain_v(ptr %p)`, + ` %replaced_cycle_cache = load ptr, ptr @${cycleCache}`, + ` call void @scr_promise_release(ptr %replaced_cycle_cache)`, + ` store ptr %cycle_cache_owned, ptr @${cycleCache}`, + ] + : []), + ` ret ptr %p`, + `}`, + ``, + ); + for (const line of sp) out.push(line); + } + for (const line of emitter.emitGenScaffolding()) out.push(line); + return out; +} + +/** The await-call entry of an async function (`sc_aw_*`): one direct + * `await f(...)` as a call returning the awaited value, so the call site + * needs no promise of its own. A fiberless body runs on the caller's + * stack as in its spawn wrapper; a clean result then takes the await's + * one microtask turn (scr_await_hop) and is returned without ever + * materializing the promise, which nothing else could observe. A throw + * builds the rejected promise exactly as the spawn wrapper would and + * awaits it, so the rejection reaches the awaiter on the same turn. Other + * bodies pack their arguments as the spawn wrapper does and enter + * scr_async_spawn_await_*, which parks the caller on the promise as soon + * as the body first suspends. The adopting entry (`sc_ad_*`, for + * `return f(...)` in an async function) adds the turn of the then job + * that adopts the returned promise, before the await proper. */ +export function emitAsyncAwaitCall( + emitter: LlEmitter, + fn: IrFunction, + argPackLines: string[] | null, + adopt: boolean, +): string[] { + const ret = fn.returnType; + const retTy = emitter.llType(ret); + const params = fn.params.map((p, i) => `${emitter.llType(p.type)} %a${i}`); + const awaitLine = (dest: string, promise: string): string => { + switch (ret.kind) { + case "void": + emitter.declare(`declare void @scr_await_void(ptr)`); + return ` call void @scr_await_void(ptr ${promise})`; + case "f64": + case "date": + emitter.declare(`declare double @scr_await_f64(ptr)`); + return ` ${dest} = call double @scr_await_f64(ptr ${promise})`; + case "bool": + emitter.declare(`declare zeroext i1 @scr_await_bool(ptr)`); + return ` ${dest} = call zeroext i1 @scr_await_bool(ptr ${promise})`; + case "string": + emitter.declare(`declare ptr @scr_await_str(ptr)`); + return ` ${dest} = call ptr @scr_await_str(ptr ${promise})`; + default: + emitter.declare(`declare ptr @scr_await_ref(ptr)`); + return ` ${dest} = call ptr @scr_await_ref(ptr ${promise})`; + } + }; + const retLine = (v: string): string => (retTy === "void" ? ` ret void` : ` ret ${retTy} ${v}`); + const head = adopt + ? `define internal ${retTy} @${mangleAsyncAdoptCall(fn.name)}(${params.join(", ")}) ${FN_ATTRS} { ; adopt ${fn.name}` + : `define internal ${retTy} @${mangleAsyncAwaitCall(fn.name)}(${params.join(", ")}) ${FN_ATTRS} { ; await ${fn.name}`; + const args = params.join(", "); + if (adopt && argPackLines !== null) { + // A body that may suspend settles later than the adoption's then job + // runs: materialize its promise, take that turn, then await it. + emitter.declare(`declare void @scr_await_hop()`); + emitter.declare(`declare void @scr_promise_release(ptr)`); + return [ + head, + `entry:`, + ` %p = call ptr @${mangleAsyncSpawn(fn.name)}(${args})`, + ` call void @scr_await_hop()`, + awaitLine("%v", "%p"), + ` call void @scr_promise_release(ptr %p)`, + retLine("%v"), + `}`, + ``, + ]; + } + if (argPackLines !== null && emitter.stacklessAsync.has(fn.name)) { + // A stackless body's eager prefix already runs on the awaiter's + // stack, which then awaits the promise like the two separate steps. + emitter.declare(`declare void @scr_promise_release(ptr)`); + return [ + head, + `entry:`, + ` %p = call ptr @${mangleAsyncSpawn(fn.name)}(${args})`, + awaitLine("%v", "%p"), + ` call void @scr_promise_release(ptr %p)`, + retLine("%v"), + `}`, + ``, + ]; + } + if (argPackLines !== null) { + const kind = + ret.kind === "void" + ? "void" + : ret.kind === "f64" || ret.kind === "date" + ? "f64" + : ret.kind === "bool" + ? "bool" + : ret.kind === "string" + ? "str" + : "ref"; + const cret = kind === "bool" ? "zeroext i1" : retTy; + emitter.declare(`declare ${cret} @scr_async_spawn_await_${kind}(ptr, ptr)`); + emitter.needOom(); + const call = `call ${cret} @scr_async_spawn_await_${kind}(ptr @${mangleTrampoline(fn.name)}, ptr %ap)`; + return [ + head, + `entry:`, + ...argPackLines, + kind === "void" ? ` ${call}` : ` %v = ${call}`, + retLine("%v"), + `}`, + ``, + ]; + } + emitter.declare(`declare void @scr_async_inline_enter(ptr)`); + emitter.declare(`declare void @scr_async_inline_exit(ptr)`); + emitter.declare(`declare void @scr_await_hop()`); + emitter.declare(`declare ptr @scr_promise_new()`); + emitter.declare(`declare void @scr_promise_reject_pending(ptr)`); + emitter.declare(`declare void @scr_promise_release(ptr)`); + const bodyCall = `call ${retTy} @${mangleFunction(fn.name)}(${args})`; + const out = [ + head, + `entry:`, + ` %frame = alloca { ptr, ptr }`, + ` call void @scr_async_inline_enter(ptr %frame)`, + retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`, + ...emitter.pendingTestLines("%pend"), + ` call void @scr_async_inline_exit(ptr %frame)`, + // Adopting: the then job's turn comes before the settled promise's + // own reaction, whichever way the body completed. + ...(adopt ? [` call void @scr_await_hop()`] : []), + ` br i1 %pend, label %thrown, label %clean`, + `clean:`, + ` call void @scr_await_hop()`, + retLine("%r"), + `thrown:`, + ]; + if (ret.kind !== "void" && isRefCounted(ret)) { + // An escaping throw means %r is the never-read dummy (NULL). + out.push(` call void ${releaseSym(emitter.shapeHost, ret)}(ptr %r)`); + } + out.push( + ` %p = call ptr @scr_promise_new()`, + ` call void @scr_promise_reject_pending(ptr %p)`, + awaitLine("%v", "%p"), + ` call void @scr_promise_release(ptr %p)`, + retLine("%v"), + `}`, + ``, + ); + return out; +} + +/** Move a clean async return value into the current fiber's promise. */ +export function emitWasiFulfill(emitter: LlEmitter, v: LlValue | null): void { + const coro = emitter.currentCoro; + if (coro === null) + throw new InternalCompilerError("llvm emitter bug: wasm fulfillment outside async body"); + const ret = emitter.currentReturnType; + if (coro.kind === "generator") { + emitter.declare(`declare ptr @scr_gen_of_fiber(ptr)`); + const gen = emitter.B.tmp(); + emitter.B.line(`${gen} = call ptr @scr_gen_of_fiber(ptr ${coro.self})`); + switch (ret.kind) { + case "void": + return; + case "f64": + case "date": + emitter.declare(`declare void @scr_gen_out_f64(ptr, double)`); + emitter.B.line(`call void @scr_gen_out_f64(ptr ${gen}, double ${v!.name})`); + return; + case "bool": + emitter.declare(`declare void @scr_gen_out_bool(ptr, i1 zeroext)`); + emitter.B.line(`call void @scr_gen_out_bool(ptr ${gen}, i1 ${v!.name})`); + return; + default: + emitter.declare(`declare void @scr_gen_out_ref(ptr, ptr, ptr)`); + emitter.B.line( + `call void @scr_gen_out_ref(ptr ${gen}, ptr ${v!.name}, ptr ${vAdapters(emitter.shapeHost, ret).release})`, + ); + return; + } + } + emitter.declare(`declare ptr @scr_fiber_promise(ptr)`); + const pr = emitter.B.tmp(); + emitter.B.line(`${pr} = call ptr @scr_fiber_promise(ptr ${coro.self})`); + switch (ret.kind) { + case "void": + emitter.declare(`declare void @scr_promise_fulfill_void(ptr)`); + emitter.B.line(`call void @scr_promise_fulfill_void(ptr ${pr})`); + break; + case "f64": + case "date": + emitter.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`); + emitter.B.line(`call void @scr_promise_fulfill_f64(ptr ${pr}, double ${v!.name})`); + break; + case "bool": + emitter.declare(`declare void @scr_promise_fulfill_bool(ptr, i1 zeroext)`); + emitter.B.line(`call void @scr_promise_fulfill_bool(ptr ${pr}, i1 ${v!.name})`); + break; + case "string": + emitter.declare(`declare void @scr_promise_fulfill_str(ptr, ptr)`); + emitter.B.line(`call void @scr_promise_fulfill_str(ptr ${pr}, ptr ${v!.name}) ; moves in`); + break; + case "dyn": + emitter.declare(`declare void @scr_promise_resolve_dyn(ptr, ptr)`); + emitter.B.line(`call void @scr_promise_resolve_dyn(ptr ${pr}, ptr ${v!.name}) ; moves in`); + break; + default: { + const rc = vAdapters(emitter.shapeHost, ret); + emitter.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); + emitter.B.line( + `call void @scr_promise_fulfill_ref(ptr ${pr}, ptr ${v!.name}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(emitter.shapeHost, ret)})`, + ); + } + } +} + +/** The argument-pack ABI and trampoline prefix shared by async functions + * and generators. Their promise/generator completion and spawn tails stay + * with the callers below. */ +export function emitArgPackAndTrampolinePrologue( + emitter: LlEmitter, + fn: IrFunction, +): LlArgPackAndTrampolinePrologue { + const pack = mangleArgPack(fn.name); + const lifted = fn.captures !== undefined; + const fieldTys = [...(lifted ? ["ptr"] : []), ...fn.params.map((p) => emitter.llType(p.type))]; + const definitions = [`%${pack} = type { ${fieldTys.join(", ") || "i8"} } ; ${fn.name} args`]; + const sizeOf = `ptrtoint (ptr getelementptr (%${pack}, ptr null, i32 1) to ${emitter.sizeType})`; + + // Async argument packs live from the spawn to the trampoline's first + // instructions, so they take the runtime's small-block allocator. + // Generator packs keep malloc: the runtime frees never-started ones. + const smallPack = fn.generator === undefined; + const packAlloc = smallPack ? "scr_rt_calloc" : "malloc"; + const packFree = smallPack ? "scr_rt_free" : "free"; + emitter.declare(`declare void @${packFree}(ptr)`); + emitter.declare(`declare ptr @${packAlloc}(${emitter.sizeType})`); + + const tr: string[] = [ + `define internal void @${mangleTrampoline(fn.name)}(ptr %self, ptr %ap) ${FN_ATTRS} {`, + `entry:`, + ]; + const loads: string[] = []; + fieldTys.forEach((ty, i) => { + tr.push( + ` %fp${i} = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${i}`, + ` %a${i} = load ${ty}, ptr %fp${i}`, + ); + loads.push(`${ty} %a${i}`); + }); + tr.push(` call void @${packFree}(ptr %ap)`); + const ret = fn.returnType; + const retTy = emitter.llType(ret); + const bodyCall = `call ${retTy} @${mangleFunction(fn.name)}(${loads.join(", ")})`; + tr.push(retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`); + if (lifted && !emitter.coroLowered(fn)) { + tr.push(` call void @scr_closure_release(ptr %a0)`); + } + + const spawnParams = fieldTys.map((ty, i) => `${ty} %a${i}`); + const argPackLines = [ + ` %ap = call ptr @${packAlloc}(${emitter.sizeType} ${sizeOf})`, + ` %isnull = icmp eq ptr %ap, null`, + ` br i1 %isnull, label %oom, label %ok`, + `oom:`, + ` call void @sc_oom()`, + ` unreachable`, + `ok:`, + ]; + if (lifted) { + // scr_closure_retain is a header static inline — the `_v` twin is + // the exported symbol. + argPackLines.push(` %env = call ptr @scr_closure_retain_v(ptr %a0)`); + } + fieldTys.forEach((ty, i) => { + const src = lifted && i === 0 ? "%env" : `%a${i}`; + argPackLines.push( + ` %sp${i} = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${i}`, + ` store ${ty} ${src}, ptr %sp${i}`, + ); + }); + return { definitions, pack, lifted, fieldTys, ret, tr, spawnParams, argPackLines }; +} + +/** The module functions whose bodies may park their execution context: + * their emitted text mentions a suspension entry point (SUSPENDS), or + * they mention such a function (transitively). Null where async bodies + * must keep fibers regardless: wasm32 (its coroutine lowering owns async + * bodies), worker programs, and programs that use node:test subtests, + * whose `t.test()` awaits on the CURRENT fiber even from synchronous + * code. */ +export function parkingFunctions(emitter: LlEmitter): Set | null { + if (emitter.wasi || emitter.mod.workers === true) return null; + for (const decl of emitter.decls) if (/@scr_test_sub\b/.test(decl)) return null; + for (const text of emitter.fnDefText.values()) if (/@scr_test_sub\b/.test(text)) return null; + // Reverse mention edges, then propagate "may park" to every mentioner. + const mentionedBy = new Map>(); + const parks = new Set(); + const work: string[] = []; + for (const [name, text] of emitter.fnDefText) { + if (SUSPENDS.test(text)) { + parks.add(name); + work.push(name); + } + for (const callee of emitter.mentionedFunctions(text)) { + if (callee === name) continue; + let callers = mentionedBy.get(callee); + if (callers === undefined) mentionedBy.set(callee, (callers = new Set())); + callers.add(name); + } + } + while (work.length > 0) { + const callers = mentionedBy.get(work.pop()!); + if (callers === undefined) continue; + for (const caller of callers) { + if (parks.has(caller)) continue; + parks.add(caller); + work.push(caller); + } + } + return parks; +} + +/** Async functions whose calls can skip the fiber (scr_async_inline_*). + * A body qualifies only when nothing it runs can park its execution + * context: its emitted text, and the text of every module function it + * mentions (transitively), contains no suspension entry point. Such a + * body always completes before the call returns, which is exactly the + * behavior of a fiber that never suspends. Excluded conservatively: + * wasm32 (its coroutine lowering owns async bodies), module-evaluation + * bodies (their promise caches), + * `dyn` results (resolution may adopt a thenable), and every program + * that uses node:test subtests — `t.test()` runs a subtest inline and + * awaits it on the CURRENT fiber even from synchronous code. Worker + * programs keep fibers too: their fiber epilogue owns context + * termination (a stopping worker rejects and observes the promise), which + * the fiberless wrapper does not reproduce. A missed suspension still + * cannot reorder execution undetected: the runtime traps when a context parks while a + * fiberless body is on its stack. */ +export function fiberlessAsyncFunctions(emitter: LlEmitter): Set { + const parks = emitter.parkingFunctions(); + const eligible = parks === null ? new Set() : emitter.neverParkingAsync(parks); + for (const name of emitter.inlineAwaitCallees) + if (!eligible.has(name)) + throw new InternalCompilerError(`llvm emitter bug: inline await of suspending ${name}`); + return eligible; +} diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts new file mode 100644 index 000000000..7d60205e5 --- /dev/null +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -0,0 +1,2245 @@ +/* LlEmitter's module-level output: module parts, helper definitions, library declarations and FFI callback definitions. */ +import { type LlEmitter, ffiCallbackDummyLl, llStrBytes } from "./emitter.js"; +import { + mangleAsyncSpawn, + mangleClassObj, + mangleFnClosure, + mangleFunction, + mangleGlobal, + mangleVtInstance, +} from "../mangle.js"; +import { + type IrGlobal, + type IrType, + RUNTIME_ERROR_CLASSES, + RUNTIME_EVENT_CLASS, + RUNTIME_EVENT_TARGET_CLASS, + ffiCallbackType, + isFfiContextParam, + isRefCounted, + typeKey, +} from "../../ir/ir.js"; +import { + FN_ATTRS, + limitInlineDynReleases, + releaseSym, + typedRefConstructor, + vAdapters, +} from "./shapes.js"; +import { emitLlvmLayouts } from "./layouts.js"; +import { moduleRuntimeFeatures } from "../../ir/runtime-features.js"; +import { emitArraySearchHelpers } from "./array-search.js"; +import { emitClassMembershipHelper } from "./classes.js"; +import { type RegexMatcherCode, compileRegexMatcher } from "./regex-matchers.js"; +import { + MAP_ITER_DECL, + NUMBER_MAP_ENTRY_DECL, + STRING_MAP_ENTRY_DECL, +} from "./map-number-lookup.js"; +import { RUNTIME_ABI_MARKER } from "../runtime-abi.js"; +import { threadSlotsBlockDefinitions } from "./thread-slots.js"; +import { + ABSENT_FIELD_PAYLOAD, + exactIndexLines, + f64Lit, + ffiNativeParamLl, + ffiNativeReturnLl, + ffiNativeTypeLl, +} from "./common.js"; +import { literalCapWord, literalCapWord32 } from "./string-key-hash.js"; +import { hasLoneSurrogate, wtf8Bytes } from "../literals.js"; +import { undefinedArmTag } from "../../ir/analysis.js"; +import { NULLABLE_ABSENT, NULLABLE_NULL } from "./nullable-unions.js"; +import { literalEqualityDefinition } from "./string-literal-equality.js"; +import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js"; +import { emitLibraryIdentityLines } from "../library-identity-markers.js"; +import { InternalCompilerError } from "../../errors.js"; + +export function emitModuleParts(emitter: LlEmitter): string[] { + // Function bodies first (the literal/unit/fn-value tables fill as they + // emit), then the file assembles around them — the runtime ABI’s order. + const fnDefs: string[] = []; + for (const fn of emitter.mod.functions) { + const def = emitter.emitFunction(fn); + fnDefs.push(def); + emitter.fnDefText.set(fn.name, def); + } + emitter.selectStacklessAsync(fnDefs); + const errorViews: string[] = []; + for (const property of ["name", "message"]) + if (emitter.mod.functions.some((fn) => fn.name === `%error.${property}.read`)) { + emitter.declare(`declare void @scr_error_install_${property}_reader(ptr)`); + errorViews.push( + ` call void @scr_error_install_${property}_reader(ptr @${mangleFunction(`%error.${property}.read`)})`, + ); + } + for (const meta of emitter.classMeta.values()) { + if (meta.def.runtime || meta.root.def.name !== "%Error") continue; + const type: IrType = { kind: "object", className: meta.def.name }; + const adapter = emitter.liveDynRefAdapter(type); + const rc = vAdapters(emitter.shapeHost, type); + const key = typeKey(type); + const name = `sc_error_view_${errorViews.length}`; + emitter.resolveThunkDefs.push( + `define internal ptr @${name}(ptr %v) ${FN_ATTRS} {`, + `entry:`, + ` %d = call ptr ${typedRefConstructor(emitter.shapeHost, type)}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ptr ${emitter.cstr(key)}, ${emitter.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, + ` ret ptr %d`, + `}`, + ``, + ); + emitter.declare(`declare void @scr_error_register_dyn(ptr, ptr)`); + errorViews.push( + ` call void @scr_error_register_dyn(ptr @${mangleVtInstance(meta.def.name)}, ptr @${name})`, + ); + } + const layouts = emitLlvmLayouts( + emitter.shapeHost, + emitter.mod, + emitter.classMeta, + emitter.classObjs, + emitter.fnByName, + (t) => emitter.llType(t), + emitter.nullableFields, + emitter.int32Slots, + ); + const shapes = layouts.records; + const classShapes = layouts.classes; + const classObjDefs = layouts.classObjects; + const wrappers = emitter.emitFnValueDefs(); + const asyncDefs = emitter.emitAsyncScaffolding(); + const ffiCallbacks = emitter.emitFfiCallbackDefs(); + const hasNoInlineRecordClone = [...emitter.recordCloneShapes].some( + (shapeId) => (emitter.recordsById.get(shapeId)?.fields.length ?? 0) >= 16, + ); + + // Module globals, FIRST OCCURRENCE per id: a class-expression static + // instantiated through several mixin applications registers one global + // id several times (2041-mixin-values) — C's tentative definitions + // accept the duplicates without an error, LLVM rejects redefinition, so the + // storage AND the release emit once per id here. + const seenGlobalIds = new Set(); + const globals = (emitter.mod.globals ?? []).filter((g) => { + if (seenGlobalIds.has(g.id)) return false; + seenGlobalIds.add(g.id); + return true; + }); + // Refcounted globals release before main returns — the runtime ABI’s + // sc_release_globals, keeping the RC audit's live count exact. Built + // before the declaration table flushes (it adds the release symbols). + // Two spellings with distinct temp names: the normal exit and the + // uncaught-exception exit are separate blocks of the same function. + // Interned function-value closures are IMMORTAL (rc == SIZE_MAX), so + // an own-property table Object.defineProperties hung on one would + // outlive the RC audit — release it with the globals (the runtime ABI’s + // sc_release_globals tail). Only when the dispatch unit is even + // linked (defineProps is the only writer). + const runtimeFeatures = moduleRuntimeFeatures(emitter.mod); + const fnValueProps = [...emitter.fnValues]; + if (fnValueProps.length > 0) emitter.declare(`declare void @scr_box_release(ptr)`); + if (emitter.classObjs.size > 0) + emitter.declare(`declare void @scr_classobj_clear_properties(ptr)`); + const globalReleaseLines = (prefix: string): string[] => { + const lines: string[] = []; + globals.forEach((g, i) => { + if (!isRefCounted(g.type)) return; + lines.push( + ` %${prefix}${i} = load ptr, ptr @${mangleGlobal(g.id)}`, + ` call void ${releaseSym(emitter.shapeHost, g.type)}(ptr %${prefix}${i}) ; ${g.name}`, + ); + }); + fnValueProps.forEach((name, i) => { + // props sits at %ScrClosure field 3; release is NULL-tolerant — + // cleared so a second release path stays idempotent. + lines.push( + ` %${prefix}fp${i} = load ptr, ptr getelementptr inbounds (%ScrClosure, ptr @${mangleFnClosure(name)}, i64 0, i32 3)`, + ` call void @scr_box_release(ptr %${prefix}fp${i}) ; ${name}.props`, + ` store ptr null, ptr getelementptr inbounds (%ScrClosure, ptr @${mangleFnClosure(name)}, i64 0, i32 3)`, + ); + }); + for (const name of emitter.classObjs.keys()) + lines.push(` call void @scr_classobj_clear_properties(ptr @${mangleClassObj(name)})`); + return lines; + }; + // Exit listeners can read MODULE GLOBALS directly, so they must run + // BEFORE the global releases (the runtime ABI’s runExitListeners + // ordering — the atexit half becomes an idempotent no-op). + const usesEvents = runtimeFeatures.processEvents; + const usesChildProcess = runtimeFeatures.childProcess; + const usesFsWatch = runtimeFeatures.fsWatch; + // Stream-surface programs fill the loop's stream hook (the deferred + // next-tick emissions) and the emitter's post-registration flow kick + // before %main — scr_stream.c links only when the line is emitted + // (native/executable.ts gates on the same predicate). + const usesStream = runtimeFeatures.stream; + // Net-surface programs fill the loop's net hooks (and the netSocket + // handle-dispatch ops for the checked-dynamic boundary); http-surface + // programs additionally stamp the httpReq/httpRes ops — the C main's + // install lines, gated on the same predicates native/executable.ts links by. + const usesNet = runtimeFeatures.net; + const usesDgram = runtimeFeatures.dgram; + const usesHttp2 = runtimeFeatures.http2; + const usesHttp = runtimeFeatures.http; + // Fetch-referencing programs register native fetch before main. + // Runtime selection compiles scr_fetch.c on the same predicate. + const usesFetch = runtimeFeatures.fetch; + const snapshotsTlsCa = runtimeFeatures.tls || runtimeFeatures.tlsCa; + // The process verdict has the same precedence as the C reference + // emitter: node:test owns the final status when present; otherwise an + // embedded process.exitCode owns it; ordinary programs return zero. + const usesNodeTest = runtimeFeatures.nodeTest; + // Declared NOW — the extern block flushes before main assembles. + if (usesEvents) emitter.declare(`declare void @scr_events_install()`); + if (usesChildProcess) emitter.declare(`declare void @scr_child_dyn_install()`); + const childStreamBoxes = + usesChildProcess && + usesStream && + emitter.classMeta.has("%Readable") && + emitter.classMeta.has("%Writable") + ? [ + emitter.dyn.toDynHelper({ kind: "object", className: "%Readable" }), + emitter.dyn.toDynHelper({ kind: "object", className: "%Writable" }), + ] + : null; + // worker.stdout and worker.stderr box the stream unit's Readable the + // same way, and worker.stdin its Writable when the program has one + // (scr_worker_stdio_install, scr_worker_events.c). + const workerStdioBox = + emitter.mod.workers === true && usesStream && emitter.classMeta.has("%Readable") + ? (childStreamBoxes?.[0] ?? + emitter.dyn.toDynHelper({ kind: "object", className: "%Readable" })) + : null; + const workerStdinBox = + emitter.mod.workers === true && usesStream && emitter.classMeta.has("%Writable") + ? (childStreamBoxes?.[1] ?? + emitter.dyn.toDynHelper({ kind: "object", className: "%Writable" })) + : null; + if (childStreamBoxes || workerStdioBox || workerStdinBox) { + emitter.declare( + `@scr_stream_child_ops = external constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr }`, + ); + } + if (childStreamBoxes) + emitter.declare(`declare void @scr_child_dyn_streams_install(ptr, ptr, ptr)`); + if (workerStdioBox || workerStdinBox) + emitter.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`); + if (usesFsWatch) emitter.declare(`declare void @scr_watch_install()`); + if (emitter.ffiHasForeignCallback) emitter.declare(`declare void @scr_ffi_install()`); + if (usesStream) emitter.declare(`declare void @scr_stream_install()`); + if (usesNet) { + emitter.declare(`declare void @scr_net_install()`); + emitter.declare(`declare void @scr_net_dyn_install()`); + } + if (usesDgram) emitter.declare(`declare void @scr_dgram_install()`); + if (usesHttp2) emitter.declare(`declare void @scr_http2_dyn_install()`); + if (usesHttp) emitter.declare(`declare void @scr_http_dyn_install()`); + if (usesFetch) emitter.declare(`declare void @scr_fetch_install()`); + + if (usesNodeTest) { + emitter.declare(`declare i32 @scr_test_exit_code()`); + emitter.declare(`declare void @scr_test_init()`); + } + if (snapshotsTlsCa) { + emitter.declare(`declare void @scr_tls_ca_install()`); + } + // Inline exit listeners run when something they must beat exists: + // the refcounted-global releases, or the retained-FFI atexit ledger + // sweep (a listener may legitimately release or pump a registration, + // and only the inline call orders ahead of every atexit handler — + // the runtime ABI’s runExitListeners stance). Plain event programs + // with neither keep the atexit path, so their listener timing is + // unchanged. + const hasRefGlobals = globals.some((g) => isRefCounted(g.type)) || fnValueProps.length > 0; + const inlineExitListeners = + usesEvents && (emitter.mod.workers || hasRefGlobals || emitter.ffiHasRetainedCallback); + emitter.declare(`declare i32 @scr_exit_code_hint_get()`); + if (inlineExitListeners) { + emitter.declare(`declare void @scr_run_exit_listeners(double)`); + emitter.declare(`declare i32 @scr_exit_code_hint_get()`); + } + const exitListenerLines = (prefix: string): string[] => { + if (!inlineExitListeners) return []; + return [ + ` %${prefix}h = call i32 @scr_exit_code_hint_get()`, + ` %${prefix}hd = sitofp i32 %${prefix}h to double`, + ` call void @scr_run_exit_listeners(double %${prefix}hd)`, + ]; + }; + // The entry's exits release the module globals only where the thread + // frees its heap when its script ends (scr_exit_frees: workers, and + // builds that check for leaks); the main thread of other builds leaves + // it to the operating system. + const exitReleaseLines = (prefix: string): string[] => { + const releases = globalReleaseLines(prefix); + if (releases.length === 0) return releases; + emitter.declare(`declare zeroext i1 @scr_exit_frees()`); + return [ + ` %${prefix}.frees = call zeroext i1 @scr_exit_frees()`, + ` br i1 %${prefix}.frees, label %${prefix}.release, label %${prefix}.kept`, + `${prefix}.release:`, + ...releases, + ` br label %${prefix}.kept`, + `${prefix}.kept:`, + ]; + }; + const globalReleases = exitReleaseLines("g"); + const asyncEntry = emitter.fnByName.get(emitter.mod.entry)?.async === true; + const entryMayThrow = emitter.mayThrow.has(emitter.mod.entry); + // The event loop runs when timers appeared OR any async/generator + // function exists (the C main's hasAsync || hasGenerators || + // usesTimers gate). Generator programs run + // the loop too: its exit accounting notes still-suspended generator + // fibers as abandoned, so the RC audit downgrades exactly like the + // async loop-exhaustion story. + const runsLoop = + emitter.usesTimers || + emitter.ffiHasForeignCallback || + emitter.mod.functions.some((f) => f.async === true || f.generator !== undefined); + const uncaughtReleases = entryMayThrow && !asyncEntry ? exitReleaseLines("gu") : []; + const loopReleasesU = runsLoop ? exitReleaseLines("gl") : []; + const loopReleasesR = runsLoop ? exitReleaseLines("gr") : []; + const topRejectReleases = asyncEntry ? exitReleaseLines("gt") : []; + const topPendingReleases = asyncEntry ? exitReleaseLines("gp") : []; + const loopReportedReleases = runsLoop ? exitReleaseLines("gq") : []; + // main's epilogues read the flag / the loop entry points — declared + // HERE, before the extern block flushes. They run once, so they keep + // the out-of-line scr_exc_pending call; per-call checks inline the + // test (pendingTestLines). + if (entryMayThrow || runsLoop) emitter.declare(`declare zeroext i1 @scr_exc_pending()`); + if (runsLoop) { + emitter.declare(`declare zeroext i1 @scr_loop_run(ptr)`); + if (emitter.mod.commonJsEntry === true) + emitter.declare(`declare void @scr_loop_commonjs_entry()`); + emitter.declare(`declare zeroext i1 @scr_report_unhandled_rejections()`); + emitter.declare(`declare void @scr_discard_unhandled_rejections()`); + } + if (asyncEntry) { + emitter.declare(`declare i32 @scr_promise_finish_top_level(ptr)`); + emitter.declare(`declare void @scr_promise_rethrow_top_level(ptr)`); + emitter.declare(`declare void @scr_promise_release(ptr)`); + emitter.declare(`declare void @scr_exit_code_note(i32)`); + } + const topPendingExitLines = (): string[] => { + if (!asyncEntry) return []; + const lines: string[] = []; + // finish_top_level noted 13 unless the program set process.exitCode. + // A failed node:test run outranks either before exit listeners run. + if (usesNodeTest) { + lines.push( + ` %tla_program_exit = call i32 @scr_test_exit_code()`, + ` %tla_program_exit_zero = icmp eq i32 %tla_program_exit, 0`, + ` %tla_hint = call i32 @scr_exit_code_hint_get()`, + ` %tla_exit_status = select i1 %tla_program_exit_zero, i32 %tla_hint, i32 %tla_program_exit`, + ` call void @scr_exit_code_note(i32 %tla_exit_status)`, + ); + } + for (const line of exitListenerLines("xp")) lines.push(line); + for (const line of topPendingReleases) lines.push(line); + // An exit listener may set process.exitCode again, as in Node. + lines.push(` %tla_exit_code = call i32 @scr_exit_code_hint_get()`, ` ret i32 %tla_exit_code`); + return lines; + }; + // LIBRARY mode: the runtime entry points the generated library + // symbols delegate to — declared before the extern block flushes. + if (emitter.mod.lib !== undefined) { + if (emitter.wasi) { + emitter.declare(`declare ptr @malloc(${emitter.sizeType})`); + emitter.declare(`declare void @free(ptr)`); + } + emitter.declare(`declare void @scr_library_entry(i1 zeroext, ptr)`); + emitter.declare(`declare void @scr_library_reset()`); + emitter.declare(`declare void @scr_library_check_exc()`); + emitter.declare(`declare void @scr_library_set_sink(ptr, ptr)`); + emitter.declare(`declare void @scr_library_callback_entry_guard(ptr)`); + emitter.declare(`declare void @scr_library_arena_reset()`); + emitter.declare(`declare void @scr_library_collect()`); + if ((emitter.mod.lib.callbacks?.length ?? 0) > 0) { + // Host-callback channels: the registration define's dispatch + // (strcmp over the declared names + the runtime slot store). + // The call-site fetch pair (scr_library_cb_require/_ctx) is + // declared at ffiCall emission like every body-driven runtime + // symbol. + emitter.declare(`declare void @scr_library_cb_set(${emitter.sizeType}, ptr, ptr)`); + emitter.declare(`declare i32 @strcmp(ptr, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.params.includes("string"))) { + emitter.declare(`declare ptr @scr_library_str_in(ptr, ${emitter.sizeType})`); + } + if (emitter.mod.lib.exports.some((e) => e.params.includes("bytes"))) { + emitter.declare(`declare ptr @scr_library_bytes_in(ptr, ${emitter.sizeType}, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.params.includes("i64"))) { + emitter.declare(`declare double @scr_library_i64_in(i64, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.params.includes("u64"))) { + emitter.declare(`declare double @scr_library_u64_in(i64, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.returns === "string")) { + emitter.declare(`declare void @scr_library_str_out(ptr, ptr, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.returns === "bytes")) { + emitter.declare(`declare void @scr_library_bytes_out(ptr, ptr, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.params.includes("cstring"))) { + emitter.declare(`declare ptr @scr_library_cstr_in(ptr, ptr)`); + } + if (emitter.mod.lib.exports.some((e) => e.returns === "cstring")) { + emitter.declare(`declare ptr @scr_library_cstr_out(ptr)`); + } + } + // Helpers assemble BEFORE the declaration table flushes (they add + // write/abort declarations). + const helpers = [ + ...emitter.helperDefs(), + ...emitArraySearchHelpers(emitter.arraySearchKinds, emitter.sizeType, (line) => + emitter.declare(line), + ), + ...emitClassMembershipHelper(emitter.classMeta, emitter.sizeType), + ...(emitter.publishWalkersCache?.defs() ?? []), + ...emitter.publishedClassNamesDefs(), + ...(emitter.regions?.moduleDefs() ?? []), + ]; + if (emitter.constantNumericTables.size > 0) + emitter.declare(`declare double @scr_arr_get_number(ptr, double)`); + // Native matchers of the regex literals inside the compiled subset. + const regexMatchers = new Map(); + for (const [key, re] of emitter.regexInstances) { + const code = compileRegexMatcher( + `${re.sym}_match`, + re.pattern, + re.flags, + emitter.sizeType, + !emitter.wasi, + ); + if (code === null) continue; + regexMatchers.set(key, code); + for (const decl of code.decls) emitter.declare(decl); + } + + const out: string[] = [ + `; Generated by scriptc (LLVM backend) from ${emitter.mod.sourceFile}. Do not edit.`, + ``, + // Type shapes shared with the runtime's C ABI. ScrStr is the header + // prefix only (the flexible-array tail is concrete per literal); + // ScrLogArg is { i32 tag; 8-byte union } — i64 at offset 8 matches + // the C layout (4 bytes padding after the tag). ScrUnion/ScrClosure + // mirror scr_runtime.h field-for-field (tag reads, slot peeks, the + // fn pointer, and the caps[] tail all address through them). + `%ScrStr = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType} }`, + `%ScrLogArg = type { i32, i64 }`, + `%ScrVt = type { ${emitter.sizeType}, ${emitter.sizeType}, ptr }`, + `%ScrUnion = type { ${emitter.sizeType}, i32, ptr, ptr, ptr, i64 }`, + `%ScrClosure = type { ${emitter.sizeType}, ptr, ${emitter.sizeType}, ptr, i32, i32, ptr, ptr }`, + `%ScrFfiTable = type { ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, i8, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr }`, + `%ScrRegex = type { ${emitter.sizeType}, ptr, ptr, ptr, double, ptr, ptr, ptr }`, + // ScrArr mirrors scr_runtime.h field-for-field: { rc, len, cap, elem, + // cyc, spare, filled, ops, data, present, ext } (field numbers in + // SCR_ARR_FIELD), where filled bounds the hole-free prefix, ops points at the element type's constant RC descriptor + // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse, + // property and metadata storage (NULL until first used). Live dynamic + // stream commits swap its dense, presence and out-of-line storage while + // preserving the target object's identity. + `%ScrArr = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, i8, i8, i16, i32, ptr, ptr, ptr, ptr }`, + `%ScrArrOps = type { ptr, ptr, ptr }`, + // The ScrMap prefix { rc, key_kind, val_kind, traced } — the inline + // RC fast paths test the traced byte (shapes.ts). + ...(emitter.rcHelpers === null ? [] : [`%ScrMapRc = type { ${emitter.sizeType}, i8, i8, i8 }`]), + // The complete ScrMap and its { key, val, hash } entry, read by the + // inline number-key lookup (sc_map_entry_f64) and the inline + // iteration steps (sc_map_iter_entry): field 8 is the i32 entry + // count, field 10 the i64 absent-key filter, entries is field 11, + // nbuckets and buckets fields 13 and 14, the direct index and its i32 + // length fields 15 and 16. + ...(emitter.decls.has(NUMBER_MAP_ENTRY_DECL) || + emitter.decls.has(STRING_MAP_ENTRY_DECL) || + emitter.decls.has(MAP_ITER_DECL) + ? [ + `%ScrMapIx = type { ${emitter.sizeType}, i8, i8, i8, i8, i32, ptr, ptr, i32, i32, i64, ptr, i32, i32, ptr, ptr, i32, i32 }`, + ] + : []), + // String-keyed reference reads load the value slot of the entry + // scr_map_entry_str returns (map-number-lookup.ts). + ...(emitter.decls.has(NUMBER_MAP_ENTRY_DECL) || + emitter.decls.has(STRING_MAP_ENTRY_DECL) || + emitter.decls.has(MAP_ITER_DECL) + ? [`%ScrMapEntry = type { i64, i64, i64 }`] + : []), + // The runtime error prefix { rc, vt, name, message, code, cause } and the + // class-object shape { rc, pre, post, ctor, name } — field reads on + // builtin errors and classval loads GEP through these. + `%ScrError = type { ${emitter.sizeType}, ptr, ptr, ptr, ptr, ptr, i8, i8, i8, i8, i8, ptr, ptr }`, + `%ScrClassObj = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr }`, + // The runtime emitter prefix { rc, vt, reg, cls } — user subclasses + // embed it (classes.ts), and bare-emitter GEPs address through it. + `%ScrEmitter = type { ${emitter.sizeType}, ptr, ptr, ptr }`, + // The runtime stream layout { rc, vt, reg, cls, st } — one struct + // for all five stream classes; stream subclasses embed it. + `%ScrStream = type { ${emitter.sizeType}, ptr, ptr, ptr, ptr }`, + // The EventTarget and Event subclass prefixes { rc, vt, state, cls }. + ...(emitter.classMeta.has(RUNTIME_EVENT_TARGET_CLASS) + ? [`%ScrEventTargetObj = type { ${emitter.sizeType}, ptr, ptr, ptr }`] + : []), + ...(emitter.classMeta.has(RUNTIME_EVENT_CLASS) + ? [`%ScrEventObj = type { ${emitter.sizeType}, ptr, ptr, ptr }`] + : []), + // The catch-binding snapshot box { rc, kind, f64, b, payload, + // retain_fn, release_fn, trace_fn } — caughtTest kind reads and + // caughtNarrow payload extraction GEP through it (offsets match + // scr_runtime.h's natural alignment: kind at 8, f64 at 16, b at 24, + // payload at 32). + `%ScrCaught = type { ${emitter.sizeType}, i32, double, i8, ptr, ptr, ptr, ptr }`, + // ScrBytes { rc, len, elem(i32+pad), data, backing, brands, shared }. Indexed + // typed-array access GEPs through this directly: the IR type already + // fixes elem, so the hot path needs neither a runtime kind load nor + // the generic scr_bytes_get/set call. + `%ScrBytes = type { ${emitter.sizeType}, ${emitter.sizeType}, i32, ptr, ptr, i8, i8, i8, i8, ptr }`, + // The capture box { rc, kind, obj_retain, obj_release, obj_trace, + // slot } — TDZ reads peek the payload slot (offset 40) directly. + `%ScrBox = type { ${emitter.sizeType}, i32, ptr, ptr, ptr, i64 }`, + // The stack buffer of the emitted JSON serializers { data, len, cap }. + `%ScrJsonBuf = type { ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ${emitter.sizeType}, ${emitter.sizeType} }`, + // The dynCheck error-path spine { parent, key, index } — the emitted + // builders stack-allocate one per recursion level (dyn.ts). + `%ScrDynPath = type { ptr, ptr, ${emitter.sizeType} }`, + `%ScrIslandModule = type { ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, i32, ptr, ${emitter.sizeType}, ${emitter.sizeType} }`, + `%ScrIslandEdge = type { ptr, ptr, ptr, i32 }`, + // A generator's prefix { rc, receiver, fiber, state, out } and its + // value slot { kind, f64, b, payload, release_fn } (scr_async.c pins + // both): number and boolean yields and drives address OUT directly. + ...(emitter.usesGenSlots + ? [ + `%ScrGenSlot = type { i32, double, i8, ptr, ptr }`, + `%ScrGenHead = type { ${emitter.sizeType}, ptr, ptr, i32, %ScrGenSlot }`, + ] + : []), + ]; + for (const line of shapes.typeDefs) out.push(line); + for (const line of classShapes.typeDefs) out.push(line); + // Thread-instanced library state (abi.instance_per_thread): the + // program TU's mutable globals — module globals, run-once guards, the + // lazily-compiled regex literal caches — and the runtime globals its + // init stamps live in thread-local storage, matching the runtime + // objects compiled with -DSCR_THREAD_INSTANCES. Immutable interned + // data (string literals, unit arms, template arrays, vtables) stays + // shared. + const tl = + emitter.mod.workers === true || emitter.mod.lib?.threadInstances === true + ? "thread_local " + : ""; + out.push( + ``, + `@scr_error_vts = external ${tl}global [${RUNTIME_ERROR_CLASSES.size} x %ScrVt]`, + `declare void @scr_init()`, + `declare void @scr_lib_init(i32, ptr)`, + ...(emitter.mod.workers ? [`declare void @scr_runtime_workers_v8()`] : []), + ...(emitter.stackChecks?.size ? [`declare void @scr_stack_guard_init()`] : []), + ...(emitter.runtimeAbiMarker && emitter.mod.lib === undefined + ? [`declare void @${RUNTIME_ABI_MARKER}()`] + : []), + // Overrides the runtime's weak default (scr_lib.c). + ...(emitter.detachedExit && emitter.mod.lib === undefined + ? [`@scr_detached_exit = constant i8 1`] + : []), + ); + for (const d of emitter.decls) out.push(d); + out.push(``); + const flushedDecls = new Set(emitter.decls); + { + const tl = + emitter.mod.workers === true || emitter.mod.lib?.threadInstances === true + ? "thread_local " + : ""; + out.push(...threadSlotsBlockDefinitions(emitter.threadSlotsBlocks, tl !== "")); + for (let site = 0; site < emitter.dynReadIcSites; site++) + out.push( + `@sc_dyn_read_ic_${site} = internal ${tl}global { ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr } zeroinitializer`, + ); + } + for (const table of emitter.constantNumericTables.values()) { + const n = table.values.length; + let tableTmp = 0; + const tableIndex = exactIndexLines("%i", String(n), "i64", () => `%ix${tableTmp++}`); + out.push( + `@${table.symbol} = private constant [${n} x double] [${table.values.map((v) => `double ${f64Lit(v)}`).join(", ")}]`, + `define internal double @${table.symbol}_get(ptr %a, double %i) alwaysinline #0 {`, + `entry:`, + ` %initialized = icmp ne ptr %a, null`, + ...tableIndex.lines.map((line) => ` ${line}`), + ` %safe = and i1 %initialized, ${tableIndex.ok}`, + ` br i1 %safe, label %read, label %fallback`, + `read:`, + ` %slot = getelementptr inbounds [${n} x double], ptr @${table.symbol}, i64 0, i64 ${tableIndex.wide}`, + ` %value = load double, ptr %slot`, + ` ret double %value`, + `fallback:`, + ` %generic = call double @scr_arr_get_number(ptr %a, double %i)`, + ` ret double %generic`, + `}`, + ``, + ); + } + for (const [text, lit] of emitter.literals) { + // Immortal interned ScrStr: { rc = SIZE_MAX, len, cap = len, bytes\0 } — + // the runtime ABI’s static table, retain/release skip rc == SIZE_MAX. + // 64-bit (little-endian) targets split the capacity word: the high + // half carries the precomputed Map key hash (SCR_STR_HASH_CACHE). + // Bit 31 marks all-ASCII literals (SCR_STR_ASCII_BIT) on every target. + const capWord = + emitter.sizeType === "i64" + ? literalCapWord(wtf8Bytes(text)) + : literalCapWord32(wtf8Bytes(text)); + out.push( + `@${lit.sym} = internal global { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, [${lit.len + 1} x i8] } ` + + `{ ${emitter.sizeType} -1, ${emitter.sizeType} ${lit.len}, ${emitter.sizeType} ${capWord}, [${lit.len + 1} x i8] c"${llStrBytes(text)}" }`, + ); + } + if (emitter.literals.size > 0) out.push(``); + for (const lit of emitter.bigintLiterals.values()) { + // Immortal ScrBigInt: { rc = SIZE_MAX, len, sign, cap, limbs } with + // pointer-sized little-endian limbs (scr_bigint.c); zero keeps one + // zero limb so limb 0 is always readable. + const size = emitter.sizeType; + const limbBits = size === "i64" ? 64n : 32n; + const mask = (1n << limbBits) - 1n; + const limbs: string[] = []; + for (let m = lit.value < 0n ? -lit.value : lit.value; m > 0n; m = m >> limbBits) { + limbs.push(`${size} ${m & mask}`); + } + const len = limbs.length; + if (len === 0) limbs.push(`${size} 0`); + const sign = lit.value < 0n ? -1 : lit.value > 0n ? 1 : 0; + out.push( + `@${lit.sym} = internal global { ${size}, ${size}, i32, i32, [${limbs.length} x ${size}] } ` + + `{ ${size} -1, ${size} ${len}, i32 ${sign}, i32 ${limbs.length}, [${limbs.length} x ${size}] [${limbs.join(", ")}] }`, + ); + } + if (emitter.bigintLiterals.size > 0) out.push(``); + for (const [key, sym] of emitter.unitInstances) { + // One immortal instance per unit-armed (union, tag): tag set, payload + // slot and RC entry points zero — retain/release/collector all skip + // rc == SIZE_MAX, so these never join the RC audit or a trace walk. + const [unionId, tag] = key.split(":"); + out.push( + `@${sym} = internal global %ScrUnion { ${emitter.sizeType} -1, i32 ${tag}, ptr null, ptr null, ptr null, i64 0 } ; ${unionId} unit arm`, + ); + } + if (emitter.unitInstances.size > 0) out.push(``); + for (const [unionId, sym] of emitter.retypeGuards) { + // Thread-local like module globals: a thread's cast caches hold only + // its own boxes. Never written, the flag folds to false. + out.push(`@${sym} = internal ${tl}global i8 0 ; ${unionId} boxes in a cast cache`); + } + if (emitter.retypeGuards.size > 0) out.push(``); + for (const [unionId, sym] of emitter.absentInstances) { + // One immortal ABSENT field-slot instance per undefined-armed union: + // the undefined arm's tag with payload 1 (ordinary unit instances + // carry 0). Every tag reader sees undefined; recordHas and the + // field reads compare tag and payload, never the address, so the + // per-object copies of separately compiled units agree. + const tag = undefinedArmTag({ kind: "union", unionId }, emitter.unionsById); + out.push( + `@${sym} = internal global %ScrUnion { ${emitter.sizeType} -1, i32 ${tag}, ptr null, ptr null, ptr null, i64 ${ABSENT_FIELD_PAYLOAD} } ; ${unionId} absent field`, + ); + } + if (emitter.absentInstances.size > 0) out.push(``); + if (emitter.needsUnionNone) + out.push( + `@sc_union_none = internal constant %ScrUnion zeroinitializer ; NULL union dummy read by inline stores`, + ``, + ); + if (emitter.nullableUnions.usesNullSentinel) { + // `null` in every `C | null | undefined` union (NULL is undefined): + // one module-scope immortal object like the ABSENT sentinel. + const S = emitter.sizeType; + out.push( + `${NULLABLE_NULL} = internal global { ${S}, ${S}, ${S}, ${S} } { ${S} -1, ${S} 0, ${S} 0, ${S} 0 } ; nullable null arm`, + ``, + ); + } + if (emitter.needsNullableAbsent) { + // The ABSENT field-slot state of every nullable-pointer union: one + // module-scope immortal object (rc == SIZE_MAX), distinct from NULL + // (undefined) and from every instance. RC entry points and the + // collector skip it like any immortal; field reads compare its address. + const S = emitter.sizeType; + out.push( + `${NULLABLE_ABSENT} = internal global { ${S}, ${S}, ${S}, ${S} } { ${S} -1, ${S} 0, ${S} 0, ${S} 0 } ; nullable absent field`, + ``, + ); + } + for (const [key, re] of emitter.regexInstances) { + // One immortal ScrRegex per (pattern, flags) literal, pointing at + // the interned source/flags strings. The bc and translated-program + // slots start null (lazy compile, cached by the runtime) — a mutable + // global, not constant. The last slot is the build-time matcher. + const matcher = regexMatchers.has(key) ? `@${re.sym}_match` : "null"; + out.push( + `@${re.sym} = internal ${tl}global %ScrRegex { ${emitter.sizeType} -1, ptr ${re.src}, ptr ${re.fl}, ptr null, double 0.0, ptr null, ptr null, ptr ${matcher} } ; ${key.replace(/\n/g, "\\n")}`, + ); + } + if (emitter.regexInstances.size > 0) out.push(``); + for (const code of regexMatchers.values()) out.push(...code.lines, ``); + for (const [, inst] of emitter.templateStringsInstances) { + // One immortal ScrArr per tagged-template site: a [N x ptr] data + // global of interned cooked-string literals, and the ScrArr header + // over it (rc == SIZE_MAX, len == cap == filled, SCR_ELEM_STR = 2, + // no REF entry points). Every dense slot is present; reads retain + // immortal strings — a no-op. + const n = inst.slots.length; + const present = n === 0 ? "zeroinitializer" : `[ ${inst.slots.map(() => "i8 1").join(", ")} ]`; + out.push( + `@${inst.sym}_data = internal constant [${n} x ptr] [ ${inst.slots.map((s) => `ptr ${s}`).join(", ")} ]`, + `@${inst.sym}_present = internal constant [${n} x i8] ${present}`, + `@${inst.sym} = internal global %ScrArr { ${emitter.sizeType} -1, ${emitter.sizeType} ${n}, ${emitter.sizeType} ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`, + ); + } + if (emitter.templateStringsInstances.size > 0) out.push(``); + for (const [text, c] of emitter.cstrs) { + // NUL-terminated byte-array constants for scr_jb_puts and indentation. + out.push(`@${c.sym} = internal constant [${c.len + 1} x i8] c"${llStrBytes(text)}"`); + } + if (emitter.cstrs.size > 0) out.push(``); + for (const [body, sym] of emitter.mapOpsConsts) { + out.push(`@${sym} = internal constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr } { ${body} }`); + } + if (emitter.mapOpsConsts.size > 0) out.push(``); + + for (const line of ffiCallbacks.globals) out.push(line); + if (ffiCallbacks.globals.length > 0) out.push(``); + for (const g of globals) { + const ty = emitter.llType(g.type); + const zero = ty === "double" ? f64Lit(0) : ty === "ptr" ? "null" : "false"; + const debug = emitter.debug?.global(g); + out.push( + `@${mangleGlobal(g.id)} = internal ${tl}global ${ty} ${zero}${debug ? `, !dbg ${debug}` : ""} ; ${g.name}`, + ); + } + if (globals.length > 0) out.push(``); + for (const line of helpers) out.push(line); + for (const line of ffiCallbacks.defs) out.push(line); + for (const line of shapes.defs) out.push(line); + for (const line of classShapes.defs) out.push(line); + for (const line of classObjDefs) out.push(line); + for (const line of emitter.walkers.defs) out.push(line); + for (const line of emitter.dyn.defs) out.push(line); + for (const line of wrappers) out.push(line); + for (const line of asyncDefs) out.push(line); + for (const line of emitter.resolveThunkDefs) out.push(line); + out.push(fnDefs[0] ?? ""); + for (let i = 1; i < fnDefs.length; i++) out.push("", fnDefs[i]!); + out.push(""); + + // main(): scr_init, the program-dependent error-vt interval stamps, + // scr_lib_init(argc, argv), then the entry function. An uncaught + // exception escaping top-level code prints and exits 1 (Node). + // The exception epilogue is emitted when the entry may throw. + const stamps: string[] = [...errorViews]; + for (const iv of emitter.errorIntervals) { + const fields: [number, number][] = [ + [0, iv.pre], + [1, iv.post], + ]; + for (const [field, value] of fields) { + stamps.push( + ` store ${emitter.sizeType} ${value}, ptr getelementptr inbounds ([${RUNTIME_ERROR_CLASSES.size} x %ScrVt], ptr @scr_error_vts, i64 0, i64 ${iv.kind}, i32 ${field})${field === 1 ? ` ; ${iv.lib}` : ""}`, + ); + } + } + if (emitter.emitterInterval !== null) { + // The runtime emitter vtable's interval (emitterVtStampLines): bare + // EventEmitter instances answer instanceof and dispatch dynamic + // teardown under THIS module's preorder numbering. + out.push(`@scr_emitter_vt = external ${tl}global %ScrVt`, ``); + stamps.push( + ` store ${emitter.sizeType} ${emitter.emitterInterval.pre}, ptr getelementptr inbounds (%ScrVt, ptr @scr_emitter_vt, i64 0, i32 0)`, + ` store ${emitter.sizeType} ${emitter.emitterInterval.post}, ptr getelementptr inbounds (%ScrVt, ptr @scr_emitter_vt, i64 0, i32 1) ; EventEmitter`, + ); + } + for (const iv of emitter.streamIntervals) { + // The runtime stream vtables' intervals (streamVtStampLines) — the + // emitter story: instanceof and dynamic teardown dispatch through + // them. + out.push(`@${iv.vt} = external ${tl}global %ScrVt`); + stamps.push( + ` store ${emitter.sizeType} ${iv.pre}, ptr getelementptr inbounds (%ScrVt, ptr @${iv.vt}, i64 0, i32 0)`, + ` store ${emitter.sizeType} ${iv.post}, ptr getelementptr inbounds (%ScrVt, ptr @${iv.vt}, i64 0, i32 1) ; ${iv.lib}`, + ); + } + if (emitter.streamIntervals.length > 0) out.push(``); + if ([...emitter.literals.keys(), ...emitter.cstrs.keys()].some(hasLoneSurrogate)) { + // The runtime skips surrogate work until a lone surrogate may exist + // (scr_wtf8_any); its own producers note one, and literals do here. + out.push(`declare void @scr_wtf8_note()`, ``); + stamps.push(` call void @scr_wtf8_note()`); + } + if (emitter.errorIntervals.length > 0 && emitter.tracedShapes.has("object:%Error")) { + // The cycle fixpoint marked the Error hierarchy (a user subclass + // holds cycle-capable fields — capability is hierarchy-uniform), so + // the runtime's own error allocations need collector headers too. + // Declared inline: the extern block already flushed (LLVM is + // order-free, the helper-defs precedent). + out.push(`declare void @scr_error_set_traced()`, ``); + stamps.push(` call void @scr_error_set_traced()`); + } + if ((entryMayThrow || runsLoop) && emitter.mod.lib === undefined) { + // Declared inline: the extern block already flushed (LLVM is + // order-free — the scr_error_set_traced precedent). Only the + // printer emits here (nothing else declares it); scr_exc_pending + // and the loop entry points rode the Set before the flush. + out.push( + `declare void @scr_exc_print_uncaught()`, + `declare zeroext i1 @scr_exc_handle_uncaught(i1 zeroext)`, + ``, + ); + } + if (emitter.mod.lib !== undefined) { + // LIBRARY mode: no @main — the profile-declared external + // symbols specified by the library IR instead. + for (const line of emitter.emitLibDefs(globals, globalReleaseLines, stamps)) out.push(line); + out.push(...emitter.inlineRcTail(flushedDecls)); + out.push(`attributes #0 = { sanitize_address }`); + if (emitter.usesCoroutines) out.push(`attributes #1 = { sanitize_address presplitcoroutine }`); + if (hasNoInlineRecordClone) out.push(`attributes #2 = { noinline sanitize_address }`); + out.push(``); + out.push(...emitter.fieldAliasMetadata()); + if (emitter.debug !== null) out.push(emitter.debug.render()); + return out; + } + out.push( + `define i32 @${emitter.mod.workers ? "sc_context_entry" : emitter.wasi ? "__main_argc_argv" : "main"}(i32 %argc, ptr %argv) ${FN_ATTRS} {`, + `entry:`, + ...(emitter.runtimeAbiMarker ? [` call void @${RUNTIME_ABI_MARKER}()`] : []), + ...(emitter.mod.workers ? [` call void @scr_runtime_workers_v8()`] : []), + ` call void @scr_init()`, + // Each context (the main thread, every worker) installs its stack + // guard before running compiled code that checks it. + ...(emitter.stackChecks?.size ? [` call void @scr_stack_guard_init()`] : []), + ...stamps, + // Event-surface programs (signal/exit listeners) fill the loop's + // nullable event hooks before %main — scr_events.c links only when + // this line is emitted (native/executable.ts gates on the same predicate). + ...(usesEvents ? [` call void @scr_events_install()`] : []), + ...(usesChildProcess ? [` call void @scr_child_dyn_install()`] : []), + ...(childStreamBoxes + ? [ + ` call void @scr_child_dyn_streams_install(ptr @scr_stream_child_ops, ptr @${childStreamBoxes[0]}, ptr @${childStreamBoxes[1]})`, + ] + : []), + ...(workerStdioBox || workerStdinBox + ? [ + ` call void @scr_worker_stdio_install(ptr @scr_stream_child_ops, ptr ${workerStdioBox ? `@${workerStdioBox}` : "null"}, ptr ${workerStdinBox ? `@${workerStdinBox}` : "null"})`, + ] + : []), + // fs.watch programs fill the loop's watch hooks the same way — + // scr_watch.c links only when this line is emitted. + ...(usesFsWatch ? [` call void @scr_watch_install()`] : []), + ...(emitter.ffiHasForeignCallback ? [` call void @scr_ffi_install()`] : []), + ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), + ...(usesFetch ? [` call void @scr_fetch_install()`] : []), + ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), + ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), + ...(usesDgram ? [` call void @scr_dgram_install()`] : []), + ...(usesHttp2 ? [` call void @scr_http2_dyn_install()`] : []), + ...(usesStream ? [` call void @scr_stream_install()`] : []), + ` call void @scr_lib_init(i32 %argc, ptr %argv)`, + // node:test takes Node's --test-update-snapshots out of argv first. + ...(usesNodeTest ? [` call void @scr_test_init()`] : []), + ...(asyncEntry + ? [` %top = call ptr @${mangleAsyncSpawn(emitter.mod.entry)}()`] + : [` call void @${mangleFunction(emitter.mod.entry)}()`]), + // Uncaught exception from top-level code: Node exits 1. + ...(entryMayThrow && !asyncEntry + ? [ + ` %exc = call zeroext i1 @scr_exc_pending()`, + ` br i1 %exc, label %uncaught, label %ok`, + `uncaught:`, + ` %handled = call zeroext i1 @scr_exc_handle_uncaught(i1 false)`, + ` br i1 %handled, label %ok, label %fatal`, + `fatal:`, + ` call void @scr_exc_print_uncaught()`, + ...exitListenerLines("xu"), + ...uncaughtReleases, + ` %failure_code_0 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_0`, + `ok:`, + ] + : []), + // The event loop runs to exhaustion (microtasks before timers). A + // throw escaping a timer callback and unhandled promise rejections + // both exit 1, like Node — the C main's loop block exactly. + ...(runsLoop + ? [ + ...(emitter.mod.commonJsEntry === true ? [` call void @scr_loop_commonjs_entry()`] : []), + ` %loop_rejection = call zeroext i1 @scr_loop_run(ptr ${asyncEntry ? "%top" : "null"})`, + ` %lexc = call zeroext i1 @scr_exc_pending()`, + ` br i1 %lexc, label %luncaught, label %lok`, + `luncaught:`, + ` call void @scr_exc_print_uncaught()`, + ...(asyncEntry ? [` call void @scr_promise_release(ptr %top)`] : []), + ...exitListenerLines("xl"), + ...loopReleasesU, + ` %failure_code_1 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_1`, + `lok:`, + ` br i1 %loop_rejection, label %lreported, label %lclean`, + `lreported:`, + ` call void @scr_discard_unhandled_rejections()`, + ...(asyncEntry ? [` call void @scr_promise_release(ptr %top)`] : []), + ...exitListenerLines("xq"), + ...loopReportedReleases, + ` %failure_code_2 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_2`, + `lclean:`, + ...(asyncEntry + ? [ + ` %tla_status = call i32 @scr_promise_finish_top_level(ptr %top)`, + ` %tla_rejected = icmp eq i32 %tla_status, 1`, + ` br i1 %tla_rejected, label %tla_fail, label %tla_not_rejected`, + `tla_fail:`, + // The loop already delivered every earlier-checkpoint + // rejection. Drop same-checkpoint competitors before + // surfacing the fatal module verdict. + ` call void @scr_discard_unhandled_rejections()`, + ` call void @scr_promise_rethrow_top_level(ptr %top)`, + ` call void @scr_promise_release(ptr %top)`, + ` call void @scr_exc_print_uncaught()`, + ...exitListenerLines("xt"), + ...topRejectReleases, + ` %failure_code_3 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_3`, + `tla_not_rejected:`, + ` call void @scr_promise_release(ptr %top)`, + ] + : []), + ` %rej = call zeroext i1 @scr_report_unhandled_rejections()`, + ` br i1 %rej, label %lrej, label %lrok`, + `lrej:`, + ...exitListenerLines("xr"), + ...loopReleasesR, + ` %failure_code_4 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_4`, + `lrok:`, + ...(asyncEntry + ? [ + ` %tla_pending = icmp eq i32 %tla_status, 13`, + ` br i1 %tla_pending, label %tla_stuck, label %tla_ok`, + `tla_stuck:`, + ...topPendingExitLines(), + `tla_ok:`, + ] + : []), + ] + : []), + ...exitListenerLines("xn"), + ...globalReleases, + ...(usesNodeTest + ? [` %test_exit = call i32 @scr_test_exit_code()`, ` ret i32 %test_exit`] + : [` %native_exit = call i32 @scr_exit_code_hint_get()`, ` ret i32 %native_exit`]), + `}`, + ``, + ...emitter.inlineRcTail(flushedDecls), + // sanitize_address is inert under the plain pipeline; the sanitized + // lane's -fsanitize=address link activates instrumentation over the + // emitted functions too (the runtime TUs get theirs from clang). + `attributes #0 = { sanitize_address }`, + ...(emitter.usesCoroutines ? [`attributes #1 = { sanitize_address presplitcoroutine }`] : []), + ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address }`] : []), + ``, + ); + if (emitter.mod.workers) { + out.push( + `declare void @scr_context_cleanup()`, + `declare i32 @scr_worker_argc()`, + `declare ptr @scr_worker_argv()`, + `define i32 @sc_worker_entry(i32 %root) ${FN_ATTRS} {`, + `entry:`, + ` %argc = call i32 @scr_worker_argc()`, + ` %argv = call ptr @scr_worker_argv()`, + ` %result = call i32 @sc_context_entry(i32 %argc, ptr %argv)`, + ` ret i32 %result`, + `}`, + `define i32 @main(i32 %argc, ptr %argv) ${FN_ATTRS} {`, + `entry:`, + ` %result = call i32 @sc_context_entry(i32 %argc, ptr %argv)`, + ` call void @scr_context_cleanup()`, + ` ret i32 %result`, + `}`, + ); + } + out.push(...emitter.fieldAliasMetadata()); + if (emitter.debug !== null) out.push(emitter.debug.render()); + return out; +} + +/** The shared abort helpers (emitted only when referenced): the OOM + * abort of untraced shape allocation and the invalid-union-tag abort — + * both print the runtime ABI’s exact message on fd 2 and abort. */ +export function helperDefs(emitter: LlEmitter): string[] { + const defs: string[] = []; + const msgHelper = (fnName: string, msgSym: string, msg: string): void => { + // The message routes through the runtime's trap funnel: executable + // builds expand to the historical bytes-on-stderr + abort; library + // builds route to the registered panic sink (scr_runtime.h). + const bytes = Buffer.byteLength(msg, "utf8"); + emitter.declare(`declare void @scr_trap(ptr)`); + defs.push( + `@${msgSym} = internal constant [${bytes + 1} x i8] c"${llStrBytes(msg)}"`, + `define internal void @${fnName}() ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_trap(ptr @${msgSym})`, + ` unreachable`, + `}`, + ``, + ); + }; + if (emitter.needsOom) msgHelper("sc_oom", "sc_oom_msg", "scriptc: out of memory\n"); + if (emitter.needsBadTag) { + msgHelper("sc_bad_tag", "sc_bad_tag_msg", "scriptc: internal error: invalid union tag\n"); + } + if (emitter.needsBadKey) { + // The keyed-read miss on a result type that cannot say `undefined`: + // trap like an array OOB read instead of corrupting a typed slot + // (SEMANTICS.md; the C helper's message additionally interpolates + // the runtime key — a trap-path debugging nicety, never reachable + // by a program whose behavior matches Node). + msgHelper( + "sc_bad_key", + "sc_bad_key_msg", + "scriptc: TypeError: record has no key (typed slot — no undefined is representable)\n", + ); + } + if (emitter.needsRetainBox) { + // scr_box_retain is a static inline (increment-unless-immortal); + // emitted once with internal linkage, like the record retains. + defs.push( + `define internal ptr @sc_retain_box(ptr %b) ${FN_ATTRS} {`, + `entry:`, + ` %rc = load ${emitter.sizeType}, ptr %b`, + ` %imm = icmp eq ${emitter.sizeType} %rc, -1`, + ` br i1 %imm, label %done, label %inc`, + `inc:`, + ` %n = add ${emitter.sizeType} %rc, 1`, + ` store ${emitter.sizeType} %n, ptr %b`, + ` br label %done`, + `done:`, + ` ret ptr %b`, + `}`, + ``, + ); + } + // Number-key Map/Set lookups probe the runtime's direct index inline: + // while ndense is nonzero every live key is an integer below it, so an + // in-range integral key reads its slot (entry index + 1, 0 = absent) and + // any other key is absent. Maps without the index (ndense 0) hash the + // key in sc_map_entry_f64_hashed, which scans the entries of a small map + // or probes a compact bucket table as scr_map_find_f64 does, and calls + // the runtime lookup for a wide table. It stays out of line so that the + // direct-index path stays small at every call site. Both only read + // memory, so LLVM merges lookups of one key when nothing is stored in + // between (a has() followed by get(), for example). + if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL)) { + const sz = emitter.sizeType; + let mapTmp = 0; + const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`); + defs.push( + `define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`, + `entry:`, + ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, + ...(sz === "i64" + ? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`] + : [` %nd = load i32, ptr %ndp`]), + // A non-integral key inside the table is as absent as one outside it. + ...mapIndex.lines.map((line) => ` ${line}`), + ` br i1 ${mapIndex.ok}, label %slot, label %outside`, + `slot:`, + sz === "i64" + ? ` %i = add i64 ${mapIndex.wide}, 0` + : ` %i = trunc i64 ${mapIndex.wide} to ${sz}`, + ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, + ` %dense = load ptr, ptr %dp`, + ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, + ` %s = load i32, ptr %sp`, + ` %empty = icmp eq i32 %s, 0`, + ` br i1 %empty, label %miss, label %hit`, + `hit:`, + ` %e32 = sub i32 %s, 1`, + sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, + ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %entries = load ptr, ptr %ep`, + ` %found = getelementptr inbounds %ScrMapEntry, ptr %entries, ${sz} %e`, + ` ret ptr %found`, + `miss:`, + ` ret ptr null`, + `outside:`, + ` %dense.any = icmp ne ${sz} %nd, 0`, + ` br i1 %dense.any, label %miss, label %slow`, + `slow:`, + ` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`, + ` ret ptr %hashed`, + `}`, + ``, + // Hashing (scr_map_find_f64): the SameValueZero key word, its + // scr_key_mix hash (zero remapped to one) and the absent-key filter. + `define internal ptr @sc_map_entry_f64_hashed(ptr %m, double %k) noinline ${FN_ATTRS} {`, + `entry:`, + ` %nan = fcmp uno double %k, %k`, + ` %zero = fcmp oeq double %k, 0.0`, + ` %kz = select i1 %zero, double 0.0, double %k`, + ` %kzb = bitcast double %kz to i64`, + ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`, + ` %x1s = lshr i64 %kb, 30`, + ` %x1 = xor i64 %kb, %x1s`, + ` %x2 = mul i64 %x1, -4658895280553007687`, + ` %x3s = lshr i64 %x2, 27`, + ` %x3 = xor i64 %x2, %x3s`, + ` %x4 = mul i64 %x3, -7723592293110705685`, + ` %x5s = lshr i64 %x4, 31`, + ` %x5 = xor i64 %x4, %x5s`, + ` %hz = icmp eq i64 %x5, 0`, + ` %h = select i1 %hz, i64 1, i64 %x5`, + ` %f1s = lshr i64 %h, 44`, + ` %f1a = and i64 %f1s, 63`, + ` %f1 = shl i64 1, %f1a`, + ` %f2s = lshr i64 %h, 50`, + ` %f2a = and i64 %f2s, 63`, + ` %f2 = shl i64 1, %f2a`, + ` %fb = or i64 %f1, %f2`, + ` %fp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10`, + ` %filter = load i64, ptr %fp`, + ` %fm = and i64 %filter, %fb`, + ` %fin = icmp eq i64 %fm, %fb`, + ` br i1 %fin, label %table, label %miss`, + `table:`, + ` %nbp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 13`, + ...(sz === "i64" + ? [` %nb32 = load i32, ptr %nbp`, ` %nb = zext i32 %nb32 to i64`] + : [` %nb = load i32, ptr %nbp`]), + ` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %ents = load ptr, ptr %eps`, + ` %linear = icmp eq ${sz} %nb, 0`, + ` br i1 %linear, label %scan, label %buckets`, + // Small maps keep no bucket table: scan the cached entry hashes. + `scan:`, + ` %nep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, + ...(sz === "i64" + ? [` %ne32 = load i32, ptr %nep`, ` %ne = zext i32 %ne32 to i64`] + : [` %ne = load i32, ptr %nep`]), + ` br label %scan.loop`, + `scan.loop:`, + ` %si = phi ${sz} [ 0, %scan ], [ %si.next, %scan.next ]`, + ` %sdone = icmp eq ${sz} %si, %ne`, + ` br i1 %sdone, label %miss, label %scan.entry`, + `scan.entry:`, + ` %se = getelementptr inbounds %ScrMapEntry, ptr %ents, ${sz} %si`, + ` %seh = getelementptr inbounds %ScrMapEntry, ptr %se, i32 0, i32 2`, + ` %sh = load i64, ptr %seh`, + ` %sk = load i64, ptr %se`, + ` %shq = icmp eq i64 %sh, %h`, + ` %skq = icmp eq i64 %sk, %kb`, + ` %sq = and i1 %shq, %skq`, + ` br i1 %sq, label %scan.found, label %scan.next`, + `scan.found:`, + ` ret ptr %se`, + `scan.next:`, + ` %si.next = add ${sz} %si, 1`, + ` br label %scan.loop`, + // Compact tables tag each 24-bit entry index (+1) with the hash's + // top byte; wider tables take the runtime lookup. + `buckets:`, + ` %wide = icmp ugt ${sz} %nb, 16777216`, + ` br i1 %wide, label %runtime, label %probe`, + `probe:`, + ` %bp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 14`, + ` %bk = load ptr, ptr %bp`, + ` %mask = sub ${sz} %nb, 1`, + ` %ht = lshr i64 %h, 32`, + ` %ht32 = trunc i64 %ht to i32`, + ` %tag = and i32 %ht32, -16777216`, + sz === "i64" ? ` %hi = add i64 %h, 0` : ` %hi = trunc i64 %h to ${sz}`, + ` %i0 = and ${sz} %hi, %mask`, + ` br label %probe.loop`, + `probe.loop:`, + ` %pi = phi ${sz} [ %i0, %probe ], [ %pi.next, %probe.next ]`, + ` %bep = getelementptr inbounds i32, ptr %bk, ${sz} %pi`, + ` %b = load i32, ptr %bep`, + ` %bempty = icmp eq i32 %b, 0`, + ` br i1 %bempty, label %miss, label %probe.tag`, + `probe.tag:`, + ` %bt = and i32 %b, -16777216`, + ` %btq = icmp eq i32 %bt, %tag`, + ` br i1 %btq, label %probe.entry, label %probe.next`, + `probe.entry:`, + ` %bix = and i32 %b, 16777215`, + ` %bix1 = sub i32 %bix, 1`, + sz === "i32" ? ` %bi = add i32 %bix1, 0` : ` %bi = zext i32 %bix1 to ${sz}`, + ` %pe = getelementptr inbounds %ScrMapEntry, ptr %ents, ${sz} %bi`, + ` %peh = getelementptr inbounds %ScrMapEntry, ptr %pe, i32 0, i32 2`, + ` %ph = load i64, ptr %peh`, + ` %pk = load i64, ptr %pe`, + ` %phq = icmp eq i64 %ph, %h`, + ` %pkq = icmp eq i64 %pk, %kb`, + ` %pq = and i1 %phq, %pkq`, + ` br i1 %pq, label %probe.found, label %probe.next`, + `probe.found:`, + ` ret ptr %pe`, + `probe.next:`, + ` %pi.inc = add ${sz} %pi, 1`, + ` %pi.next = and ${sz} %pi.inc, %mask`, + ` br label %probe.loop`, + `miss:`, + ` ret ptr null`, + `runtime:`, + ` %r = call ptr @scr_map_entry_f64(ptr %m, double %k) memory(read) nounwind willreturn`, + ` ret ptr %r`, + `}`, + ``, + ); + } + // Map/Set iteration steps read the entries array inline: an index is + // live when it is below the entry count and its entry's cached hash is + // nonzero (a tombstone's is zero), as scr_map_iter_live decides. The + // entry count is read again at every step, so entries appended during + // the iteration are visited. + if (emitter.decls.has(MAP_ITER_DECL)) { + const sz = emitter.sizeType; + defs.push( + `define internal ptr @sc_map_iter_entry(ptr %m, double %i) ${FN_ATTRS} {`, + `entry:`, + ` %np = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, + ` %n = load i32, ptr %np`, + ` %nf = uitofp i32 %n to double`, + ` %lo = fcmp oge double %i, 0.0`, + ` %hi = fcmp olt double %i, %nf`, + ` %in = and i1 %lo, %hi`, + ` br i1 %in, label %load, label %none`, + `load:`, + ` %ix = fptoui double %i to ${sz}`, + ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, + ` %entries = load ptr, ptr %ep`, + ` %e = getelementptr inbounds %ScrMapEntry, ptr %entries, ${sz} %ix`, + ` %hp = getelementptr inbounds %ScrMapEntry, ptr %e, i32 0, i32 2`, + ` %h = load i64, ptr %hp`, + ` %live = icmp ne i64 %h, 0`, + ` br i1 %live, label %yes, label %none`, + `yes:`, + ` ret ptr %e`, + `none:`, + ` ret ptr null`, + `}`, + ``, + `define internal double @sc_map_iter_count(ptr %m) ${FN_ATTRS} {`, + `entry:`, + ` %np = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, + ` %n = load i32, ptr %np`, + ` %nf = uitofp i32 %n to double`, + ` ret double %nf`, + `}`, + ``, + ); + } + // String-key Map/Set lookups test the map's absent-key filter inline + // (scr_map.c scr_map_filter_bits): a key whose two filter bits are not + // both set is absent, so most misses on small and medium maps return + // without a call. On 64-bit targets the key's cached hash h is the high + // half of its capacity word, the runtime's widened hash is (h << 32) | h, + // and the filter reads its bits 44..55, which are the capacity word's + // own. A key without a cached hash, and every possible hit, take the + // runtime lookup. + if (emitter.decls.has(STRING_MAP_ENTRY_DECL)) { + defs.push( + `define internal ptr @sc_map_entry_str(ptr %m, ptr %k) ${FN_ATTRS} {`, + `entry:`, + ...(emitter.sizeType === "i64" + ? [ + ` %fp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10`, + ` %filter = load i64, ptr %fp`, + ` %capp = getelementptr inbounds %ScrStr, ptr %k, i32 0, i32 2`, + ` %cap = load i64, ptr %capp`, + ` %cached = icmp uge i64 %cap, 4294967296`, + ` br i1 %cached, label %test, label %slow`, + `test:`, + ` %s1 = lshr i64 %cap, 44`, + ` %i1 = and i64 %s1, 63`, + ` %b1 = shl i64 1, %i1`, + ` %s2 = lshr i64 %cap, 50`, + ` %i2 = and i64 %s2, 63`, + ` %b2 = shl i64 1, %i2`, + ` %bits = or i64 %b1, %b2`, + ` %have = and i64 %filter, %bits`, + ` %maybe = icmp eq i64 %have, %bits`, + ` br i1 %maybe, label %slow, label %absent`, + `absent:`, + ` ret ptr null`, + ] + : [` br label %slow`]), + `slow:`, + ` %r = call ptr @scr_map_entry_str(ptr %m, ptr %k)`, + ` ret ptr %r`, + `}`, + ``, + ); + } + // String comparisons enter through small internal wrappers that LLVM + // inlines at each site: interned literals and shared values compare by + // identity, and equality rejects different byte lengths, before any + // runtime call (scr_str_eq/scr_str_cmp keep the complete semantics). + if (emitter.decls.has(`declare zeroext i1 @scr_str_eq(ptr, ptr)`)) { + defs.push( + `define internal zeroext i1 @sc_str_eq(ptr %a, ptr %b) ${FN_ATTRS} {`, + `entry:`, + ` %same = icmp eq ptr %a, %b`, + ` br i1 %same, label %yes, label %lengths`, + `lengths:`, + ` %alen.p = getelementptr inbounds %ScrStr, ptr %a, i32 0, i32 1`, + ` %blen.p = getelementptr inbounds %ScrStr, ptr %b, i32 0, i32 1`, + ` %alen = load ${emitter.sizeType}, ptr %alen.p`, + ` %blen = load ${emitter.sizeType}, ptr %blen.p`, + ` %samelen = icmp eq ${emitter.sizeType} %alen, %blen`, + ` br i1 %samelen, label %bytes, label %no`, + `bytes:`, + ` %eq = call zeroext i1 @scr_str_eq(ptr %a, ptr %b)`, + ` ret i1 %eq`, + `yes:`, + ` ret i1 true`, + `no:`, + ` ret i1 false`, + `}`, + ``, + ); + } + for (const [text, name] of emitter.literalEqualities) { + defs.push( + ...literalEqualityDefinition( + name, + wtf8Bytes(text), + emitter.internLiteral(text), + emitter.sizeType, + FN_ATTRS, + ), + ``, + ); + } + // String length and charCodeAt read proven-ASCII strings inline: bit 31 + // of the capacity word (SCR_STR_ASCII_BIT, the low half's sign bit on + // little-endian targets) makes UTF-16 units bytes. Unproven strings and + // anything but an in-range index take the runtime's UTF-16 mapping. + // A scan's receiver reads inline too where runtime state is ordinary + // global storage (ScrStrScan; SCR_TL makes it thread-local otherwise). + const scanInline = emitter.mod.workers !== true && emitter.mod.lib?.threadInstances !== true; + let scanDeclared = false; + for (const helper of STRING_READ_HELPERS) { + if (!emitter.decls.has(helper.decl)) continue; + if (scanInline && !scanDeclared) { + defs.push(stringScanDecl(emitter.sizeType), ``); + scanDeclared = true; + } + defs.push(...helper.define(emitter.sizeType, FN_ATTRS, scanInline), ``); + } + // Ordering decides inline from the first differing bytes. The bytes + // before them are equal, so both strings are at the same position of a + // character: either both bytes continue characters with the same lead + // byte, or both start a character. Byte order is the UTF-16 order for + // two continuation bytes, and for two lead bytes when one starts a + // character below U+D000, whose UTF-16 unit is below every unit of a + // character with a lead byte of at least 0xED. Between lead bytes of at + // least 0xED, a supplementary character (0xF0..0xF4, a high surrogate + // unit) orders below U+E000..U+FFFF (0xEE, 0xEF), and a lead byte of + // 0xED (U+D000..U+D7FF or a lone surrogate) takes the runtime. + // Differing words without a byte of at least 0xED decide in byte + // order; other differing words decide by their first differing byte, + // and strings shorter than a word by their first byte. Otherwise a + // proven-ASCII string on either side orders by bytes (scr_str_cmp), and + // other pairs call scr_str_cmp_u16_bytes, which only reads the two + // strings, so LLVM can merge repeated comparisons of one pair. Callers + // only test the sign of the result. + if (emitter.decls.has(`declare i32 @scr_str_cmp_u16(ptr, ptr)`)) { + const fn = "scr_str_cmp_u16"; + emitter.declare("declare i64 @llvm.bswap.i64(i64)"); + emitter.declare("declare i64 @llvm.cttz.i64(i64, i1 immarg)"); + emitter.declare("declare i8 @llvm.umin.i8(i8, i8)"); + emitter.declare("declare i32 @scr_str_cmp(ptr, ptr)"); + emitter.declare("declare i32 @scr_str_cmp_u16_bytes(ptr, ptr)"); + defs.push( + `define internal i32 @${fn.replace(/^scr_/, "sc_")}(ptr %a, ptr %b) ${FN_ATTRS} {`, + `entry:`, + ` %same = icmp eq ptr %a, %b`, + ` br i1 %same, label %equal, label %lengths`, + `lengths:`, + ` %alen.p = getelementptr inbounds %ScrStr, ptr %a, i32 0, i32 1`, + ` %blen.p = getelementptr inbounds %ScrStr, ptr %b, i32 0, i32 1`, + ` %alen = load ${emitter.sizeType}, ptr %alen.p`, + ` %blen = load ${emitter.sizeType}, ptr %blen.p`, + ` %aempty = icmp eq ${emitter.sizeType} %alen, 0`, + ` %bempty = icmp eq ${emitter.sizeType} %blen, 0`, + ` %empty = or i1 %aempty, %bempty`, + ` br i1 %empty, label %order, label %words`, + // Both at least 8 bytes: the first word of each. + `words:`, + ` %along = icmp uge ${emitter.sizeType} %alen, 8`, + ` %blong = icmp uge ${emitter.sizeType} %blen, 8`, + ` %long = and i1 %along, %blong`, + ` br i1 %long, label %word, label %first`, + `word:`, + ` %awp = getelementptr inbounds %ScrStr, ptr %a, i32 1`, + ` %bwp = getelementptr inbounds %ScrStr, ptr %b, i32 1`, + ` %aw = load i64, ptr %awp, align 1`, + ` %bw = load i64, ptr %bwp, align 1`, + ` %wdiffer = icmp ne i64 %aw, %bw`, + ` br i1 %wdiffer, label %differ, label %wsame`, + // Equal first words and one length of at most 16 bytes: the word + // ending at that length covers the rest, and any difference lies + // past the equal first word. + `wsame:`, + ` %samelen = icmp eq ${emitter.sizeType} %alen, %blen`, + ` %short = icmp ule ${emitter.sizeType} %alen, 16`, + ` %tailable = and i1 %samelen, %short`, + ` br i1 %tailable, label %tail, label %order`, + `tail:`, + ` %toff = sub ${emitter.sizeType} %alen, 8`, + ` %atp = getelementptr inbounds i8, ptr %awp, ${emitter.sizeType} %toff`, + ` %btp = getelementptr inbounds i8, ptr %bwp, ${emitter.sizeType} %toff`, + ` %at = load i64, ptr %atp, align 1`, + ` %bt = load i64, ptr %btp, align 1`, + ` %tdiffer = icmp ne i64 %at, %bt`, + ` br i1 %tdiffer, label %differ, label %equal`, + `first:`, + ` %adata = getelementptr inbounds %ScrStr, ptr %a, i32 1`, + ` %bdata = getelementptr inbounds %ScrStr, ptr %b, i32 1`, + ` %x = load i8, ptr %adata`, + ` %y = load i8, ptr %bdata`, + ` %fdiffer = icmp ne i8 %x, %y`, + ` br i1 %fdiffer, label %bytes, label %order`, + // Two differing words. When neither has a byte of at least 0xED + // (a byte b is when its high bit and bit 7 of (b & 0x7f) + 0x13 are + // set), byte-swapped little-endian words compare in byte order. + // Otherwise the lowest set byte of their xor is the first differing + // byte. + `differ:`, + ` %dx = phi i64 [ %aw, %word ], [ %at, %tail ]`, + ` %dy = phi i64 [ %bw, %word ], [ %bt, %tail ]`, + ` %xl = and i64 %dx, 9187201950435737471`, + ` %yl = and i64 %dy, 9187201950435737471`, + ` %xa = add i64 %xl, 1374463283923456787`, + ` %ya = add i64 %yl, 1374463283923456787`, + ` %xh = and i64 %dx, %xa`, + ` %yh = and i64 %dy, %ya`, + ` %hh = or i64 %xh, %yh`, + ` %whigh = and i64 %hh, -9187201950435737472`, + ` %dlow = icmp eq i64 %whigh, 0`, + ` br i1 %dlow, label %wordorder, label %locate`, + `wordorder:`, + ` %xsw = call i64 @llvm.bswap.i64(i64 %dx)`, + ` %ysw = call i64 @llvm.bswap.i64(i64 %dy)`, + ` %wbelow = icmp ult i64 %xsw, %ysw`, + ` %wsign = select i1 %wbelow, i32 -1, i32 1`, + ` ret i32 %wsign`, + `locate:`, + ` %dxor = xor i64 %dx, %dy`, + ` %dtz = call i64 @llvm.cttz.i64(i64 %dxor, i1 true)`, + ` %dshift = and i64 %dtz, 56`, + ` %dxs = lshr i64 %dx, %dshift`, + ` %dys = lshr i64 %dy, %dshift`, + ` %dxb = trunc i64 %dxs to i8`, + ` %dyb = trunc i64 %dys to i8`, + ` br label %bytes`, + // The first differing bytes. + `bytes:`, + ` %bx = phi i8 [ %x, %first ], [ %dxb, %locate ]`, + ` %by = phi i8 [ %y, %first ], [ %dyb, %locate ]`, + ` %bmin = call i8 @llvm.umin.i8(i8 %bx, i8 %by)`, + ` %blow = icmp ult i8 %bmin, -19`, + ` br i1 %blow, label %byte, label %high`, + // Both at least 0xED. A lone surrogate (0xED) takes the runtime; + // supplementary lead bytes (0xF0..0xF4) order below 0xEE and 0xEF + // once 0x10 is subtracted from them. + `high:`, + ` %xed = icmp eq i8 %bx, -19`, + ` %yed = icmp eq i8 %by, -19`, + ` %ed = or i1 %xed, %yed`, + ` br i1 %ed, label %order, label %planes`, + `planes:`, + ` %xsupp = icmp uge i8 %bx, -16`, + ` %ysupp = icmp uge i8 %by, -16`, + ` %xlow = add i8 %bx, -16`, + ` %ylow = add i8 %by, -16`, + ` %xk = select i1 %xsupp, i8 %xlow, i8 %bx`, + ` %yk = select i1 %ysupp, i8 %ylow, i8 %by`, + ` %kbelow = icmp ult i8 %xk, %yk`, + ` %ksign = select i1 %kbelow, i32 -1, i32 1`, + ` ret i32 %ksign`, + `byte:`, + ` %below = icmp ult i8 %bx, %by`, + ` %sign = select i1 %below, i32 -1, i32 1`, + ` ret i32 %sign`, + `order:`, + ` %acw.p = getelementptr inbounds %ScrStr, ptr %a, i32 0, i32 2`, + ` %bcw.p = getelementptr inbounds %ScrStr, ptr %b, i32 0, i32 2`, + ` %acw = load i32, ptr %acw.p`, + ` %bcw = load i32, ptr %bcw.p`, + ` %cws = or i32 %acw, %bcw`, + ` %anyascii = icmp slt i32 %cws, 0`, + ` br i1 %anyascii, label %memorder, label %units`, + `memorder:`, + ` %m = call i32 @scr_str_cmp(ptr %a, ptr %b)`, + ` ret i32 %m`, + `units:`, + ` %c = call i32 @scr_str_cmp_u16_bytes(ptr %a, ptr %b) nounwind willreturn memory(argmem: read)`, + ` ret i32 %c`, + `equal:`, + ` ret i32 0`, + `}`, + ``, + ); + } + // The declarations these helpers added must land in the extern block, + // which already flushed — append here instead (LLVM is order-free). + return defs.length > 0 ? [...defs] : defs; +} + +/** LIBRARY mode: the profile-declared external definitions — the + * export-map wrappers plus init / sink-registration / reset / collect. + * Plain `define` (not `define internal`) — the exact linkage distinction + * that separates the executable lane's @main from everything else. The + * bodies delegate every runtime half to scr_library.c, mirroring the C + * emission line for line, so the two lanes are identical by + * construction. */ +export function emitLibDefs( + emitter: LlEmitter, + globals: IrGlobal[], + globalReleaseLines: (prefix: string) => string[], + stamps: string[], +): string[] { + const lib = emitter.mod.lib!; + const autoReset = lib.resultResetSymbol === null; + const out: string[] = [``, `; ── library-mode entries (profile: ${lib.profileName}) ──`, ``]; + // Every entry's prologue records its external symbol in the funnel's + // current-entry slot (structured trap-teaching field 2); the symbols + // live as internal constants, one per entry. + const symConst = (sym: string): string => `@sc_lib_sym_${sym}`; + const emitSymConst = (sym: string): void => { + out.push( + `${symConst(sym)} = internal constant [${Buffer.byteLength(sym, "utf8") + 1} x i8] c"${llStrBytes(sym)}"`, + ); + }; + emitSymConst(lib.initSymbol); + emitSymConst(lib.sinkRegisterSymbol); + if (lib.callbackRegisterSymbol !== null && lib.callbackRegisterSymbol !== undefined) + emitSymConst(lib.callbackRegisterSymbol); + if (lib.resultResetSymbol !== null) emitSymConst(lib.resultResetSymbol); + if (lib.collectSymbol !== null) emitSymConst(lib.collectSymbol); + for (const e of lib.exports) emitSymConst(e.symbol); + out.push(``); + if (emitter.wasi) { + for (const symbol of ["scriptc_alloc", "scriptc_free"]) emitSymConst(symbol); + out.push( + `define ptr @scriptc_alloc(i32 %size) ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst("scriptc_alloc")})`, + ` %p = call ptr @malloc(i32 %size)`, + ` ret ptr %p`, + `}`, + ``, + `define void @scriptc_free(ptr %p) ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst("scriptc_free")})`, + ` call void @free(ptr %p)`, + ` ret void`, + `}`, + ``, + ); + } + // The runtime detected-trap overlay table (scr_runtime.h declares it, + // the library trap funnel consults it): flat code/teaching/remediation + // triples, one per runtime trap code (SC4013–SC4019) the profile + // declares text for. The funnel assembles the structured sink message. + // The empty table still defines the symbols the funnel links against. + const ovlCells: string[] = []; + lib.trapOverlays.forEach((o, i) => { + const cell = (name: string, text: string | undefined): void => { + if (text === undefined) { + ovlCells.push("ptr null"); + return; + } + const sym = `@sc_lib_ovl_${i}_${name}`; + out.push( + `${sym} = internal constant [${Buffer.byteLength(text, "utf8") + 1} x i8] c"${llStrBytes(text)}"`, + ); + ovlCells.push(`ptr ${sym}`); + }; + cell("code", o.code); + cell("teach", o.teaching); + cell("rem", o.remediation); + }); + out.push( + ovlCells.length === 0 + ? `@scr_library_trap_overlays = constant [1 x ptr] zeroinitializer` + : `@scr_library_trap_overlays = constant [${ovlCells.length} x ptr] [${ovlCells.join(", ")}]`, + `@scr_library_trap_overlays_len = constant ${emitter.sizeType} ${lib.trapOverlays.length}`, + ``, + ); + // The init entry: full deterministic reset-and-reevaluate. Program + // globals release and zero first (run-once guards included), then the + // runtime session reset, the error-vt interval stamps verbatim from + // the executable main, %main itself, and the escaped-exception check. + const zeroStores = globals.map((g) => { + const ty = emitter.llType(g.type); + const zero = ty === "double" ? f64Lit(0) : ty === "ptr" ? "null" : "false"; + return ` store ${ty} ${zero}, ptr @${mangleGlobal(g.id)} ; ${g.name}`; + }); + out.push( + `define void @${lib.initSymbol}() ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_entry(i1 zeroext true, ptr ${symConst(lib.initSymbol)}) ; init always resets the result arena`, + ...globalReleaseLines("ci"), + ...zeroStores, + ` call void @scr_library_reset()`, + ...stamps, + ` call void @${mangleFunction(emitter.mod.entry)}()`, + ` call void @scr_library_check_exc()`, + ` ret void`, + `}`, + ``, + `define void @${lib.sinkRegisterSymbol}(ptr %fn, ptr %ctx) ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_callback_entry_guard(ptr ${symConst(lib.sinkRegisterSymbol)})`, + ` call void @scr_library_set_sink(ptr %fn, ptr %ctx)`, + ` ret void`, + `}`, + ``, + ); + if (lib.callbacks !== undefined && lib.callbacks.length > 0) { + // Host-callback channels: the per-channel name constants (the + // registration dispatch's strcmp operands), the per-channel + // unregistered-call trap constants (the ffiCall sites' + // scr_library_cb_require operands), and the registration function: + // a pure store dispatch + // (the sink registration's rule — no entry prologue, no poison + // guard) whose first operation rejects callback-time re-entry + // (SC4026). An unknown or NULL name is a defined -1, never a store. + for (const cb of lib.callbacks) { + out.push( + `@sc_lib_cb_name_${cb.slot} = internal constant [${Buffer.byteLength(cb.name, "utf8") + 1} x i8] c"${llStrBytes(cb.name)}"`, + `@sc_lib_cb_trap_${cb.slot} = internal constant [${Buffer.byteLength(cb.unregisteredTrap, "utf8") + 1} x i8] c"${llStrBytes(cb.unregisteredTrap)}"`, + ); + } + out.push( + ``, + `define i32 @${lib.callbackRegisterSymbol}(ptr %name, ptr %fn, ptr %ctx) ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_callback_entry_guard(ptr ${symConst(lib.callbackRegisterSymbol!)})`, + ` %isnull = icmp eq ptr %name, null`, + ` br i1 %isnull, label %miss, label %try0`, + ); + lib.callbacks.forEach((cb, i) => { + const next = i + 1 < lib.callbacks!.length ? `try${i + 1}` : "miss"; + out.push( + `try${i}: ; channel '${cb.name}'`, + ` %cmp${i} = call i32 @strcmp(ptr %name, ptr @sc_lib_cb_name_${cb.slot})`, + ` %eq${i} = icmp eq i32 %cmp${i}, 0`, + ` br i1 %eq${i}, label %set${i}, label %${next}`, + `set${i}:`, + ` call void @scr_library_cb_set(${emitter.sizeType} ${cb.slot}, ptr %fn, ptr %ctx)`, + ` ret i32 0`, + ); + }); + out.push(`miss:`, ` ret i32 -1`, `}`, ``); + } + if (lib.identity !== undefined && emitter.emitLibraryIdentity) { + // Profile-declared identity getters (the ask-2 sidecar's boot-time + // pairing fence): pure data returns with NO entry prologue — exempt + // from the poisoned guard and every runtime touch (ratified), so a + // host can read them before init and after a trap. The u64 rides + // i64 two's-complement (LLVM integer constants are signed). + for (const line of emitLibraryIdentityLines(lib.identity, FN_ATTRS)) out.push(line); + } + if (lib.resultResetSymbol !== null) { + out.push( + `define void @${lib.resultResetSymbol}() ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst(lib.resultResetSymbol)})`, + ` call void @scr_library_arena_reset()`, + ` ret void`, + `}`, + ``, + ); + } + if (lib.collectSymbol !== null) { + out.push( + `define void @${lib.collectSymbol}() ${FN_ATTRS} {`, + `entry:`, + ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst(lib.collectSymbol)})`, + ` call void @scr_library_collect() ; arena reset + a full cycle collection`, + ` ret void`, + `}`, + ``, + ); + } + for (const e of lib.exports) { + const params: string[] = []; + const body: string[] = [ + ` call void @scr_library_entry(i1 zeroext ${autoReset ? "true" : "false"}, ptr ${symConst(e.symbol)})`, + ]; + const args: string[] = []; + if (e.inboundBytesTrap !== undefined) { + // The bytes-in helper's trap message: the compiler-assembled + // structured trap-teaching form (0x01 text 0x1F SC4012 0x1F symbol + // [0x1F remediation]) consumed by the runtime sink. + const trapBytes = Buffer.byteLength(e.inboundBytesTrap, "utf8"); + out.push( + `@sc_lib_bytes_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundBytesTrap)}"`, + ``, + ); + } + if (e.inboundIntTrap !== undefined) { + // The i64/u64-in helpers' host-contract trap message (ask 4): an + // inbound integer past ±(2^53−1) cannot ride f64 exactly. Same + // assembled structured form, same SC4012 code, same + // emission-invariance argument as the bytes trap. + const trapBytes = Buffer.byteLength(e.inboundIntTrap, "utf8"); + out.push( + `@sc_lib_int_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundIntTrap)}"`, + ``, + ); + } + if (e.inboundCstringTrap !== undefined) { + // The cstring-in helper's host-contract trap message (a NULL + // `const char *`): same assembled structured form and SC4012 code. + const trapBytes = Buffer.byteLength(e.inboundCstringTrap, "utf8"); + out.push( + `@sc_lib_cstr_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundCstringTrap)}"`, + ``, + ); + } + e.params.forEach((cls, i) => { + switch (cls) { + case "f64": + params.push(`double %a${i}`); + args.push(`double %a${i}`); + break; + case "bool": + params.push(`i8 %a${i}`); + body.push(` %c${i} = icmp ne i8 %a${i}, 0`); + args.push(`i1 %c${i}`); + break; + case "u8": + params.push(`i8 %a${i}`); + body.push(` %c${i} = uitofp i8 %a${i} to double`); + args.push(`double %c${i}`); + break; + case "u32": + params.push(`i32 %a${i}`); + body.push(` %c${i} = uitofp i32 %a${i} to double`); + args.push(`double %c${i}`); + break; + case "i32": + params.push(`i32 %a${i}`); + body.push(` %c${i} = sitofp i32 %a${i} to double`); + args.push(`double %c${i}`); + break; + case "i64": + // Inbound declared-integer edge (ask 4): the helper converts + // exactly or delivers the host-contract trap (past ±(2^53−1) + // the value cannot ride f64 without rounding). + params.push(`i64 %a${i}`); + body.push( + ` %c${i} = call double @scr_library_i64_in(i64 %a${i}, ptr @sc_lib_int_trap_${e.symbol})`, + ); + args.push(`double %c${i}`); + break; + case "u64": + params.push(`i64 %a${i}`); + body.push( + ` %c${i} = call double @scr_library_u64_in(i64 %a${i}, ptr @sc_lib_int_trap_${e.symbol})`, + ); + args.push(`double %c${i}`); + break; + case "string": + params.push(`ptr %a${i}_ptr`, `${emitter.sizeType} %a${i}_len`); + body.push( + ` %c${i} = call ptr @scr_library_str_in(ptr %a${i}_ptr, ${emitter.sizeType} %a${i}_len)`, + ); + args.push(`ptr %c${i}`); + break; + case "bytes": + params.push(`ptr %a${i}_ptr`, `${emitter.sizeType} %a${i}_len`); + body.push( + ` %c${i} = call ptr @scr_library_bytes_in(ptr %a${i}_ptr, ${emitter.sizeType} %a${i}_len, ptr @sc_lib_bytes_trap_${e.symbol})`, + ); + args.push(`ptr %c${i}`); + break; + case "cstring": + params.push(`ptr %a${i}`); + body.push( + ` %c${i} = call ptr @scr_library_cstr_in(ptr %a${i}, ptr @sc_lib_cstr_trap_${e.symbol})`, + ); + args.push(`ptr %c${i}`); + break; + } + }); + if (e.returns === "string" || e.returns === "bytes") { + params.push(`ptr %out`, `ptr %out_len`); + } + const target = `@${mangleFunction(e.fnName)}`; + const callArgs = args.join(", "); + let retType = "void"; + switch (e.returns) { + case "void": + body.push( + ` call void ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` ret void`, + ); + break; + case "f64": + retType = "double"; + body.push( + ` %r = call double ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` ret double %r`, + ); + break; + case "i64": + case "u64": + // The outbound declared-integer edge (ask 4): every value + // reaching this return was PROVEN whole and inside the class's + // range at compile time, so the fp-to-int conversion is exact + // by construction — the crossing carries the mathematically + // exact integer the f64 held. + retType = "i64"; + body.push( + ` %r = call double ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` %z = ${e.returns === "i64" ? "fptosi" : "fptoui"} double %r to i64`, + ` ret i64 %z`, + ); + break; + case "bool": + retType = "i8"; + body.push( + ` %r = call i1 ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` %z = zext i1 %r to i8`, + ` ret i8 %z`, + ); + break; + case "string": + body.push( + ` %r = call ptr ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` call void @scr_library_str_out(ptr %r, ptr %out, ptr %out_len)`, + ` ret void`, + ); + break; + case "bytes": + body.push( + ` %r = call ptr ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` call void @scr_library_bytes_out(ptr %r, ptr %out, ptr %out_len)`, + ` ret void`, + ); + break; + case "cstring": + // The result's arena copy, returned as `const char *`. + retType = "ptr"; + body.push( + ` %r = call ptr ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` %z = call ptr @scr_library_cstr_out(ptr %r)`, + ` ret ptr %z`, + ); + break; + } + out.push( + `define ${retType} @${e.symbol}(${params.join(", ")}) ${FN_ATTRS} { ; library export ${e.fnName}`, + `entry:`, + ...body, + `}`, + ``, + ); + } + return out; +} + +/** C-callable scalar callback trampolines. A callback with an explicit + * context entry receives the closure at that exact ABI position. A raw + * callback loads it from a call-scoped TLS slot installed around the + * outer native call. */ +export function emitFfiCallbackDefs(emitter: LlEmitter): { globals: string[]; defs: string[] } { + const globals: string[] = []; + const defs: string[] = []; + if (emitter.ffiCallbackAdapters.size === 0) return { globals, defs }; + emitter.declare(`declare void @scr_trap(ptr)`); + const expired = emitter.cstr( + "scriptc: native callback invoked outside its call-scoped lifetime\n", + ); + const released = emitter.cstr("scriptc: native callback invoked outside its retained lifetime\n"); + for (const adapter of emitter.ffiCallbackAdapters.values()) { + const cb = adapter.callback; + if (adapter.tls !== null) + globals.push(`@${adapter.tls} = internal thread_local global ptr null`); + if (adapter.global !== null) + globals.push(`@${adapter.global} = internal thread_local global ptr null`); + if (adapter.table !== null) { + globals.push( + `@${adapter.table} = internal ${emitter.mod.workers ? "thread_local " : ""}global %ScrFfiTable zeroinitializer`, + ); + } + const params = cb.params.flatMap((param, i): string[] => { + if (isFfiContextParam(param)) return [`ptr %ctx`]; + if (param === "string" || param === "bytes") { + return [`ptr %a${i}`, `${emitter.sizeType} %a${i}_len`]; + } + return [`${ffiNativeParamLl(param, emitter.ffiExtendNarrowIntegers)} %a${i}`]; + }); + if (cb.invoke === "foreign") { + if ( + adapter.table === null || + !cb.params.some(isFfiContextParam) || + cb.returns !== "void" || + cb.params.some((param) => param === "i64" || param === "u64" || param === "pointer") + ) { + throw new InternalCompilerError( + "llvm emitter bug: invalid foreign FFI callback descriptor", + ); + } + const dispatch = `${adapter.symbol}_dispatch`; + const scriptArgs: string[] = []; + const dispatchBody: string[] = [ + `define internal void @${dispatch}(ptr %cb, ptr %call) ${FN_ATTRS} {`, + `entry:`, + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ]; + for (let i = 0; i < cb.params.length; i++) { + const param = cb.params[i]!; + if (isFfiContextParam(param)) continue; + switch (param) { + case "f64": + case "f32": + case "i8": + case "u16": + case "i16": + emitter.declare(`declare double @scr_ffi_call_get_f64(ptr, ${emitter.sizeType})`); + dispatchBody.push( + ` %s${i} = call double @scr_ffi_call_get_f64(ptr %call, ${emitter.sizeType} ${i})`, + ); + scriptArgs.push(`double %s${i}`); + break; + case "bool": + emitter.declare(`declare zeroext i1 @scr_ffi_call_get_bool(ptr, ${emitter.sizeType})`); + dispatchBody.push( + ` %s${i} = call zeroext i1 @scr_ffi_call_get_bool(ptr %call, ${emitter.sizeType} ${i})`, + ); + scriptArgs.push(`i1 %s${i}`); + break; + case "u8": + case "u32": + case "i32": + emitter.declare(`declare double @scr_ffi_call_get_${param}(ptr, ${emitter.sizeType})`); + dispatchBody.push( + ` %s${i} = call double @scr_ffi_call_get_${param}(ptr %call, ${emitter.sizeType} ${i})`, + ); + scriptArgs.push(`double %s${i}`); + break; + case "cstring": + case "string": + case "bytes": { + emitter.declare(`declare ptr @scr_ffi_call_get_data(ptr, ${emitter.sizeType})`); + emitter.declare( + `declare ${emitter.sizeType} @scr_ffi_call_get_len(ptr, ${emitter.sizeType})`, + ); + dispatchBody.push( + ` %data${i} = call ptr @scr_ffi_call_get_data(ptr %call, ${emitter.sizeType} ${i})`, + ` %len${i} = call ${emitter.sizeType} @scr_ffi_call_get_len(ptr %call, ${emitter.sizeType} ${i})`, + ); + if (param === "bytes") { + emitter.declare(`declare ptr @scr_bytes_from_data(ptr, ${emitter.sizeType})`); + dispatchBody.push( + ` %s${i} = call ptr @scr_bytes_from_data(ptr %data${i}, ${emitter.sizeType} %len${i})`, + ); + } else { + emitter.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${emitter.sizeType})`); + dispatchBody.push( + ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %data${i}, ${emitter.sizeType} %len${i})`, + ); + } + scriptArgs.push(`ptr %s${i}`); + break; + } + } + } + dispatchBody.push( + ` call void %fn(${[`ptr %cb`, ...scriptArgs].join(", ")})`, + ` ret void`, + `}`, + ``, + ); + for (const line of dispatchBody) defs.push(line); + + emitter.declare(`declare ptr @scr_ffi_call_new(ptr, ptr, ptr, ${emitter.sizeType})`); + emitter.declare(`declare void @scr_ffi_post(ptr)`); + defs.push( + `define internal void @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, + `entry:`, + ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0`, + ` %call = call ptr @scr_ffi_call_new(ptr @${adapter.table}, ptr %cb, ptr @${dispatch}, ${emitter.sizeType} ${cb.params.length})`, + ); + for (let i = 0; i < cb.params.length; i++) { + const param = cb.params[i]!; + if (isFfiContextParam(param)) continue; + if (param === "cstring") { + emitter.declare(`declare void @scr_ffi_call_copy_cstring(ptr, ${emitter.sizeType}, ptr)`); + defs.push( + ` call void @scr_ffi_call_copy_cstring(ptr %call, ${emitter.sizeType} ${i}, ptr %a${i})`, + ); + } else if (param === "string" || param === "bytes") { + emitter.declare( + `declare void @scr_ffi_call_copy_${param}(ptr, ${emitter.sizeType}, ptr, ${emitter.sizeType})`, + ); + defs.push( + ` call void @scr_ffi_call_copy_${param}(ptr %call, ${emitter.sizeType} ${i}, ptr %a${i}, ${emitter.sizeType} %a${i}_len)`, + ); + } else if (param === "f32" || param === "i8" || param === "u16" || param === "i16") { + const nativeTy = ffiNativeTypeLl(param); + const op = param === "f32" ? "fpext" : param === "u16" ? "uitofp" : "sitofp"; + emitter.declare(`declare void @scr_ffi_call_set_f64(ptr, ${emitter.sizeType}, double)`); + defs.push( + ` %wide${i} = ${op} ${nativeTy} %a${i} to double`, + ` call void @scr_ffi_call_set_f64(ptr %call, ${emitter.sizeType} ${i}, double %wide${i})`, + ); + } else { + const nativeTy = ffiNativeTypeLl(param); + emitter.declare( + `declare void @scr_ffi_call_set_${param}(ptr, ${emitter.sizeType}, ${nativeTy})`, + ); + defs.push( + ` call void @scr_ffi_call_set_${param}(ptr %call, ${emitter.sizeType} ${i}, ${nativeTy} %a${i})`, + ); + } + } + defs.push(` call void @scr_ffi_post(ptr %call)`, ` ret void`, `}`, ``); + continue; + } + defs.push( + `define internal ${ffiNativeReturnLl(cb.returns, emitter.ffiExtendNarrowIntegers)} @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, + `entry:`, + adapter.tls !== null + ? ` %cb = load ptr, ptr @${adapter.tls}` + : adapter.global !== null + ? ` %cb = load ptr, ptr @${adapter.global}` + : ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0`, + ` %missing = icmp eq ptr %cb, null`, + ` br i1 %missing, label %expired, label %ready`, + `expired:`, + ` call void @scr_trap(ptr ${adapter.callback.lifetime === "call" ? expired : released})`, + ` unreachable`, + `ready:`, + ...emitter.pendingTestLines("%pending"), + ` br i1 %pending, label %skip, label %invoke`, + `skip:`, + ` ret ${ffiCallbackDummyLl(cb)}`, + `invoke:`, + ); + // Validate every native pointer before allocating any copy-in value. + // A later bad slot therefore cannot leak an earlier materialization. + for (let i = 0; i < cb.params.length; i++) { + const param = cb.params[i]!; + if (param !== "cstring" && param !== "string" && param !== "bytes") continue; + const invalid = `%invalid${i}`; + defs.push(` %null${i} = icmp eq ptr %a${i}, null`); + if (param === "cstring") { + defs.push(` ${invalid} = or i1 %null${i}, false`); + } else { + defs.push( + ` %nonempty${i} = icmp ne ${emitter.sizeType} %a${i}_len, 0`, + ` ${invalid} = and i1 %null${i}, %nonempty${i}`, + ); + } + const message = + param === "cstring" + ? "scriptc: native callback passed a NULL cstring\n" + : `scriptc: native callback passed a NULL ${param} span with nonzero length\n`; + defs.push( + ` br i1 ${invalid}, label %invalid_param${i}, label %param_ok${i}`, + `invalid_param${i}:`, + ` call void @scr_trap(ptr ${emitter.cstr(message)})`, + ` unreachable`, + `param_ok${i}:`, + ); + } + defs.push( + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ); + const scriptArgs: string[] = []; + for (let i = 0; i < cb.params.length; i++) { + const param = cb.params[i]!; + if (isFfiContextParam(param)) continue; + switch (param) { + case "i64": + case "u64": + case "pointer": { + const ty = ffiNativeTypeLl(param); + emitter.declare(`declare ptr @scr_bigint_from_${param}(${ty})`); + defs.push(` %s${i} = call ptr @scr_bigint_from_${param}(${ty} %a${i})`); + scriptArgs.push(`ptr %s${i}`); + break; + } + case "f64": + scriptArgs.push(`double %a${i}`); + break; + case "f32": + case "i8": + case "u16": + case "i16": { + const nativeTy = ffiNativeTypeLl(param); + const op = param === "f32" ? "fpext" : param === "u16" ? "uitofp" : "sitofp"; + defs.push(` %s${i} = ${op} ${nativeTy} %a${i} to double`); + scriptArgs.push(`double %s${i}`); + break; + } + case "bool": + defs.push(` %s${i} = icmp ne i8 %a${i}, 0`); + scriptArgs.push(`i1 %s${i}`); + break; + case "u8": + defs.push(` %s${i} = uitofp i8 %a${i} to double`); + scriptArgs.push(`double %s${i}`); + break; + case "u32": + defs.push(` %s${i} = uitofp i32 %a${i} to double`); + scriptArgs.push(`double %s${i}`); + break; + case "i32": + defs.push(` %s${i} = sitofp i32 %a${i} to double`); + scriptArgs.push(`double %s${i}`); + break; + case "cstring": + emitter.declare(`declare ${emitter.sizeType} @strlen(ptr)`); + emitter.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${emitter.sizeType})`); + defs.push( + ` %len${i} = call ${emitter.sizeType} @strlen(ptr %a${i})`, + ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %a${i}, ${emitter.sizeType} %len${i})`, + ); + scriptArgs.push(`ptr %s${i}`); + break; + case "string": + emitter.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${emitter.sizeType})`); + defs.push( + ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %a${i}, ${emitter.sizeType} %a${i}_len)`, + ); + scriptArgs.push(`ptr %s${i}`); + break; + case "bytes": + emitter.declare(`declare ptr @scr_bytes_from_data(ptr, ${emitter.sizeType})`); + defs.push( + ` %s${i} = call ptr @scr_bytes_from_data(ptr %a${i}, ${emitter.sizeType} %a${i}_len)`, + ); + scriptArgs.push(`ptr %s${i}`); + break; + } + } + const ft = ffiCallbackType(cb); + const internalRet = emitter.llType(ft.ret); + const callArgs = [`ptr %cb`, ...scriptArgs].join(", "); + if (cb.lifetime === "retained") { + emitter.declare(`declare ptr @scr_closure_retain_v(ptr)`); + emitter.declare(`declare void @scr_closure_release_v(ptr)`); + defs.push(` %invoke_pin = call ptr @scr_closure_retain_v(ptr %cb)`); + } + if (cb.returns === "void") { + defs.push( + ` call void %fn(${callArgs})`, + ...(cb.lifetime === "retained" + ? [` call void @scr_closure_release_v(ptr %invoke_pin)`] + : []), + ` ret void`, + `}`, + ``, + ); + continue; + } + defs.push(` %result = call ${internalRet} %fn(${callArgs})`); + if (cb.lifetime === "retained") { + defs.push(` call void @scr_closure_release_v(ptr %invoke_pin)`); + } + switch (cb.returns) { + case "i64": + case "u64": + case "pointer": { + const ty = ffiNativeTypeLl(cb.returns); + emitter.declare(`declare ${ty} @scr_bigint_to_${cb.returns}(ptr)`); + emitter.declare(`declare void @scr_bigint_release(ptr)`); + defs.push( + ` %out = call ${ty} @scr_bigint_to_${cb.returns}(ptr %result)`, + ` call void @scr_bigint_release(ptr %result)`, + ` ret ${ty} %out`, + ); + break; + } + case "f64": + defs.push(` ret double %result`); + break; + case "f32": + defs.push(` %out = fptrunc double %result to float`, ` ret float %out`); + break; + case "bool": + defs.push(` %out = zext i1 %result to i8`, ` ret i8 %out`); + break; + case "u8": + case "i8": + case "u16": + case "i16": + case "u32": + emitter.declare(`declare double @scr_bit_ushr(double, double)`); + defs.push( + ` %coerced = call double @scr_bit_ushr(double %result, double ${f64Lit(0)})`, + ` %wide = fptoui double %coerced to i32`, + ); + if (cb.returns !== "u32") { + const nativeTy = ffiNativeTypeLl(cb.returns); + defs.push(` %out = trunc i32 %wide to ${nativeTy}`, ` ret ${nativeTy} %out`); + } else { + defs.push(` ret i32 %wide`); + } + break; + case "i32": + emitter.declare(`declare double @scr_bit_or(double, double)`); + defs.push( + ` %coerced = call double @scr_bit_or(double %result, double ${f64Lit(0)})`, + ` %out = fptosi double %coerced to i32`, + ` ret i32 %out`, + ); + break; + } + defs.push(`}`, ``); + } + return { globals, defs }; +} + +export function emitParts(emitter: LlEmitter): string[] { + const parts = [ + ...limitInlineDynReleases(emitter.emitModuleParts()), + ...emitter.disposeObserverGates(), + ]; + if (!emitter.executableTls) return parts; + // A worker executable's thread-locals all live in the executable's own + // TLS block: the program's internal ones, and the runtime's external + // ones, because the runtime is always linked statically into the + // executable. So on ELF they can all use the local-exec model: LLVM's + // default general-dynamic sequence (relaxed by the linker to `mov %fs:0` + // plus an add), and even initial-exec (relaxed to `mov $offset` plus an + // indexed access), become one %fs-relative operand LLVM can fold into + // loads and compares. Darwin's thread-local variables have one model. + return parts.map((part) => + part + .replace( + /^(@[-$._A-Za-z0-9]+ = internal )thread_local global /gm, + "$1thread_local(localexec) global ", + ) + .replace( + /^(@[-$._A-Za-z0-9]+ = external )thread_local global /gm, + "$1thread_local(localexec) global ", + ), + ); +} diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts new file mode 100644 index 000000000..e641f3ecd --- /dev/null +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -0,0 +1,1985 @@ +/* LlEmitter's statement emission: the statement switch, try/catch, switch, loop copies, speculative writes and string self-concatenation. */ +import { type LlEmitter, type LlScopeEntry, adoptingAwaitOf } from "./emitter.js"; +import { CAUGHT, type IrExpr, type IrStmt, STRING, isRefCounted } from "../../ir/ir.js"; +import { emitStringSliceSnapshot } from "./string-slices.js"; +import { + canDeferSplitPiece, + emitSplitCursor, + emitSplitNext, + emitSplitScratch, + emitSplitSnapshot, + emitSplitSpanNext, + loadSplitSnapshot, + storeSplitSnapshot, + stringSplit, +} from "./split-loops.js"; +import { + type StackObjectField, + type StackObjectSource, + emitStackObject, + stackObjectFields, + stackObjectStruct, +} from "./stack-objects.js"; +import { emitStackUnion } from "./stack-unions.js"; +import { emitBorrowedArrayRead, emitLocalArrayRead } from "./local-array-reads.js"; +import { emitStackMapRead } from "./map-read-lifetimes.js"; +import { allocateLocalUnion, storeLocalUnion } from "./local-union-storage.js"; +import { boxAccess, boxNewCall, elemAccess } from "./shapes.js"; +import { dynHoleRead } from "./loop-array-borrows.js"; +import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js"; +import { emitDiscardedSplice } from "./expr-containers.js"; +import { InternalCompilerError } from "../../errors.js"; +import { emitDenseArrayGet, emitDenseArrayPop, emitDenseArraySet } from "./dense-array-access.js"; +import { emitBorrowedInput } from "./borrowed-inputs.js"; +import { f64Lit, llvmCommentText, sizeToDouble } from "./common.js"; +import { RESULT_DST_ID, RESULT_ENTRY_PREFIX } from "./record-results.js"; +import { + matchIntegerArrayForLoop, + matchIntegerBytesForLoop, + matchIntegerCountedForLoop, +} from "../../ir/integer-loops.js"; +import { emitByteWindowGuard, matchByteWindow } from "./byte-windows.js"; +import { countedDenseShape, emitDenseLoopGuard, matchDenseArrayLoop } from "./dense-array-loops.js"; +import { emitCountedLoopLimit } from "./counted-loops.js"; +import { mangleLocal } from "../mangle.js"; +import { LlvmUnsupportedError } from "./unsupported.js"; +import type { LlValue } from "./expr-context.js"; +import { SCALAR_UNION_RESULT } from "./int32-abi.js"; +import { emitLiteralSwitch } from "./switch-dispatch.js"; +import { + SPECULATIVE_RANGE, + emitSafeIntegerCheck, + emitSpeculativeInteger, + planSpeculativeLoop, + speculativeReads, + speculativeWriteTarget, +} from "./speculative-integers.js"; +import { + emitConcatInputs, + emitMixedConcat, + numberPart, + stringParts, +} from "./string-construction.js"; +import { emitStringInputs } from "./string-lifetimes.js"; + +export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { + const B = emitter.B; + emitter.frames.push([]); + switch (s.kind) { + case "varDecl": { + if (s.init && emitter.int32Locals.has(s.localId)) { + emitter.storeInt32Local(s.localId, emitter.emitExpr(s.init), s.init); + break; + } + const b = emitter.binding(s.localId); + if (emitter.walkBorrows.has(s.localId) && b.kind === "local") { + // A borrowed walk pointer: no retain now, no release at scope exit. + const v = s.init === null ? "null" : emitter.emitReadReceiver(s.init).name; + B.line(`store ptr ${v}, ptr ${b.slot}`); + break; + } + const slice = emitter.scalarStringSlices.get(s.localId); + if (slice) { + const snapshot = emitStringSliceSnapshot(emitter, slice); + emitter.stringSlices.set(s.localId, snapshot); + emitter.scopes[emitter.scopes.length - 1]!.push({ slot: snapshot.source, type: STRING }); + break; + } + const split = emitter.privateSplitLocals.get(s.localId); + if (split) { + const snapshot = storeSplitSnapshot(emitter, emitSplitSnapshot(emitter, split)); + emitter.storedSplits.set(s.localId, snapshot); + emitter.scopes[emitter.scopes.length - 1]!.push( + { slot: snapshot.sourceSlot, type: STRING }, + { slot: snapshot.separatorSlot, type: STRING }, + ); + break; + } + const stackObject = emitter.localStackObjects.get(s.localId); + if (stackObject) { + const value = emitStackObject(emitter, stackObject, (fields) => + emitter.ownScopeFields(fields), + ); + B.line(`store ptr ${value.name}, ptr ${b.slot}`); + break; + } + if (s.init && emitter.reassignedStackObjects.has(s.localId)) { + const slot = emitter.stackObjectSlot(s.localId, s.init.type); + emitStackObject( + emitter, + s.init as StackObjectSource, + (fields) => emitter.ownScopeFields(fields), + slot, + ); + B.line(`store ptr ${slot}, ptr ${b.slot}`); + break; + } + const localUnion = emitter.localStackUnions.get(s.localId); + if (localUnion) { + const union = emitStackUnion(emitter, localUnion); + B.line(`store ptr ${union.value.name}, ptr ${b.slot}`); + if (union.payload) { + emitter.toFrame(union.payload); + emitter.scopes[emitter.scopes.length - 1]!.push({ + slot: union.payloadSlot, + type: union.payload.type, + }); + } + break; + } + const localRead = emitter.localArrayReads.get(s.localId); + if (localRead) { + const owner = emitLocalArrayRead( + emitter, + !localRead.borrow && emitter.unwrittenLocalArrayRead(s, localRead) + ? { ...localRead, borrow: true } + : localRead, + b.slot, + ); + if (owner) emitter.scopes[emitter.scopes.length - 1]!.push(owner); + break; + } + const mapRead = emitter.mapReadLifetimes.locals.get(s.localId); + if (mapRead) { + const result = emitStackMapRead(emitter, mapRead); + B.line(`store ptr ${result.value.name}, ptr ${b.slot}`); + if (result.owner) emitter.scopes[emitter.scopes.length - 1]!.push(result.owner); + break; + } + const localStorage = emitter.localUnionStorageProofs.get(s.localId); + if (localStorage && s.init) { + const storage = allocateLocalUnion(emitter, localStorage); + emitter.localUnionStorage.set(s.localId, storage); + storeLocalUnion(emitter, storage, s.init); + B.line(`store ptr ${storage.box}, ptr ${b.slot}`); + if (storage.owner && storage.ownerType) + emitter.scopes[emitter.scopes.length - 1]!.push({ + slot: storage.owner, + type: storage.ownerType, + }); + break; + } + if (b.kind === "boxed") { + const stack = emitter.stackCaptures.emit(emitter, s.localId, b.type); + if (stack) { + B.line(`store ptr ${stack.box}, ptr ${b.slot}`); + if (stack.owner) emitter.scopes[emitter.scopes.length - 1]!.push(stack.owner); + if (s.init) { + const value = emitter.emitExpr(s.init); + if (isRefCounted(value.type)) emitter.toFrame(value); + emitter.boxSet(stack.box, b.type, value.name); + } + break; + } + // Box FIRST, then evaluate the initializer: a named function + // expression's closure captures this box during init evaluation. + // A SCALAR TDZ box rides an ARR-kind box: the value lives in a + // one-element array cell, so the empty (NULL) slot stays the + // not-yet-initialized sentinel — a raw scalar slot has no spare + // bit pattern to spend on it (stmts.ts's varDecl). + const boxNew = + b.local!.tdz === true && boxAccess(b.type) !== "ref" + ? (emitter.declare(`declare ptr @scr_box_new(i32)`), `call ptr @scr_box_new(i32 3)`) + : boxNewCall(emitter.shapeHost, b.type); + const box = B.tmp(); + B.line(`${box} = ${boxNew} ; let ${b.local!.name} (boxed)`); + B.line(`store ptr ${box}, ptr ${b.slot}`); + emitter.scopes[emitter.scopes.length - 1]!.push({ + slot: b.slot, + type: b.type, + boxed: true, + }); + if (s.init === null) { + // An empty TDZ box whose payload can legitimately be NULL + // starts at its distinct sentinel (tdzEmptySentinel). + const sentinel = b.local!.tdz === true ? emitter.tdzEmptySentinel(b.type) : null; + if (sentinel !== null) B.line(`store ptr ${sentinel}, ptr ${emitter.boxSlot(box)}`); + break; + } + const v = emitter.emitExpr(s.init); + if (isRefCounted(v.type)) emitter.toHeap(v); // the box takes ownership + if (b.local!.tdz === true && boxAccess(b.type) !== "ref") { + emitter.tdzScalarInit(box, b.type, v.name); + } else { + emitter.boxSet(box, b.type, v.name); + } + break; + } + if (s.init === null) { + // Declared, uninitialized (`let x: number;`): reset the slot — + // inside a loop the previous iteration's scope exit released the + // old value and left a stale pointer (NULL-tolerant releases). + if (isRefCounted(b.type)) { + B.line(`store ptr null, ptr ${b.slot}`); + emitter.scopes[emitter.scopes.length - 1]!.push({ slot: b.slot, type: b.type }); + } + break; + } + if (emitter.loopArrayBorrows.has(s) && s.init.kind === "arrayGet") { + emitBorrowedArrayRead(emitter, s.init, b.slot); + break; + } + const holeRead = emitter.loopArrayBorrows.has(s) ? dynHoleRead(s.init) : null; + if (holeRead && s.init.kind === "ternary") { + // A hole reads as the immortal undefined value, which needs no owner. + const present = emitter.emitExpr(s.init.cond); + const filled = B.newLabel("borrow.hole.filled"), + missing = B.newLabel("borrow.hole.missing"), + done = B.newLabel("borrow.hole.done"); + B.condBr(present.name, filled, missing); + B.startBlock(filled); + emitBorrowedArrayRead(emitter, holeRead, b.slot); + B.br(done); + B.startBlock(missing); + emitter.declare("declare ptr @scr_dyn_undefined()"); + const undef = B.tmp(); + B.line(`${undef} = call ptr @scr_dyn_undefined()`); + B.line(`store ptr ${undef}, ptr ${b.slot}`); + B.br(done); + B.startBlock(done); + break; + } + // A stable lexical alias cannot outlive its unchanged local owner. + // Whole-value uses still retain their own copies, so returning or + // storing the alias preserves the ordinary heap representation. + // Boxed captures, reassigned sources and projections need ownership. + if ( + b.kind === "local" && + b.local && + isRefCounted(b.type) && + emitter.stableCallBindings.has(s.localId) && + (emitter.canBorrowCallArgument(s.init) || emitter.unwrittenAlias(s)) + ) { + const v = emitter.emitReadReceiver(s.init); + B.line(`store ptr ${v.name}, ptr ${b.slot}`); + break; + } + const v = emitter.emitExpr(s.init); + if (b.kind === "global") emitter.toHeap(v); + else emitter.toFrame(v); + B.line(`store ${emitter.llType(b.type)} ${v.name}, ptr ${b.slot}`); + emitter.storeIntegerView(s.localId, v, s.init); + if (isRefCounted(b.type)) { + emitter.scopes[emitter.scopes.length - 1]!.push({ slot: b.slot, type: b.type }); + emitter.noteLastUseMove(s, b); + } + break; + } + case "assign": { + if (emitter.int32Locals.has(s.localId)) { + emitter.storeInt32Local(s.localId, emitter.emitExpr(s.value), s.value); + break; + } + const owner = emitter.reboundOwnerSlots.get(s.localId); + if (owner !== undefined && !emitter.walkBorrows.has(s.localId)) { + // A rebound borrowed parameter: the new value moves into the owner + // slot, replacing (and releasing) the previous owned value. This + // precedes the in-place string append, which would release the + // caller's borrow. + const param = emitter.binding(s.localId); + const slot = param.slot; + const v = emitter.emitExpr(s.value); + emitter.toFrame(v); + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${owner}`); + emitter.releaseFrame(old, param.type); + B.line(`store ptr ${v.name}, ptr ${slot}`); + B.line(`store ptr ${v.name}, ptr ${owner}`); + break; + } + if (emitter.reassignedStackObjects.has(s.localId)) { + // The new value may read the old one: build it apart (its + // reference fields owned by this statement until it completes), + // then copy it over the binding's slot. The binding takes over the + // new fields; the old ones are released after the slot no longer + // holds them. + const struct = stackObjectStruct(s.value.type); + let owned: readonly StackObjectField[] = []; + const value = emitStackObject(emitter, s.value as StackObjectSource, (fields) => { + owned = fields; + for (const field of fields) emitter.ownSlot(field.ptr, field.type); + }); + const target = emitter.stackObjectSlot(s.localId, s.value.type); + const old = stackObjectFields(emitter, s.value.type, target).map((field) => { + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${field.ptr}`); + return { name: t, type: field.type }; + }); + const bits = B.tmp(); + B.line(`${bits} = load ${struct}, ptr ${value.name}`); + B.line(`store ${struct} ${bits}, ptr ${target}`); + emitter.disownSlots(owned); + for (const field of old) emitter.releaseFrame(field.name, field.type); + break; + } + const localStorage = emitter.localUnionStorage.get(s.localId); + if (localStorage) { + storeLocalUnion(emitter, localStorage, s.value); + break; + } + const self = matchStringSelfConcat(s.localId, s.value); + if (self) { + emitter.emitStringSelfConcatAssign(s.localId, self.left, self.suffix, false); + break; + } + const b = emitter.binding(s.localId); + if (emitter.walkBorrows.has(s.localId) && b.kind === "local") { + // Rebinding a borrowed walk pointer releases nothing. + B.line(`store ptr ${emitter.emitReadReceiver(s.value).name}, ptr ${b.slot}`); + break; + } + const v = emitter.emitExpr(s.value); + if (b.kind === "global" && !s.initializes) emitter.checkGlobalTdz(s.localId); + if (b.kind === "boxed") { + emitter.writeBindingBox(emitter.loadBox(b.slot), b.local!, v.name, s.initializes); + // The RHS remains frame-owned until a possible TDZ throw passes. + if (isRefCounted(v.type)) emitter.toHeap(v); + break; + } + if (b.kind === "global") emitter.toHeap(v); + else emitter.toFrame(v); + if (isRefCounted(b.type)) { + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${b.slot}`); + if (b.kind === "global") emitter.releaseHeap(old, b.type); + else emitter.releaseFrame(old, b.type); + } + B.line(`store ${emitter.llType(b.type)} ${v.name}, ptr ${b.slot}`); + emitter.storeIntegerView(s.localId, v, s.value); + if (b.kind === "global" && emitter.immortalGlobals.has(s.localId)) { + // An Immutable module constant (contracts/check.ts proved it is + // fresh, publishable and never written): its graph becomes + // immortal, so reads skip reference counting and the collector + // never visits it. + const lines = emitter.publishWalkers.emitPublish(v.name, v.type, s.loc); + for (const line of lines) B.line(line.trimStart()); + // Reads of this global skip retains from here on, which is only + // sound once the graph really is immortal. Node.js never fails + // here, so a refusal means the contract proof was wrong: stop + // rather than keep running with a mortal graph read as immortal. + if (lines.length > 0) { + emitter.declare(`declare zeroext i1 @scr_exc_pending()`); + emitter.declare(`declare void @scr_trap(ptr)`); + const refused = B.tmp(); + B.line(`${refused} = call zeroext i1 @scr_exc_pending()`); + const stop = B.newLabel("immutable.refused"); + const done = B.newLabel("immutable.published"); + B.condBr(refused, stop, done); + B.startBlock(stop); + B.line( + `call void @scr_trap(ptr ${emitter.cstr("scriptc: internal error: an Immutable value could not be published\n")})`, + ); + B.terminate("unreachable"); + B.startBlock(done); + } + } + break; + } + case "exprStmt": + // A statement-position splice never observes its removed elements. + if (!emitDiscardedSplice(emitter, s.expr)) emitter.emitDiscarded(s.expr); + break; + case "arraySet": { + // Evaluation order matches JS: array, index, then value. Ownership + // of a refcounted value moves into the array (the runtime releases + // the replaced element itself). A local receiver that the index and + // value cannot rebind is read in place, as for field stores; the + // released element cannot reach that binding. + if (emitter.emitArraySelfConcat(s)) break; + const arr = emitter.emitStableReceiver(s.arr, [s.index, s.value]); + const idx = emitter.emitExpr(s.index); + const v = emitter.emitExpr(s.value); + if (s.arr.type.kind !== "array") + throw new InternalCompilerError("llvm emitter bug: arraySet on non-array"); + const acc = elemAccess(s.arr.type.elem); + emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); + if (acc === "ref") emitter.toHeap(v); + emitDenseArraySet( + emitter, + arr.name, + idx, + s.index, + acc, + s.arr.type.elem, + v.name, + emitter.provenDenseIndex(s.arr, s.index), + ); + break; + } + case "arraySetLength": { + // Pop's `length = 0` store on an empty array only matters when the + // array can be frozen (published); its operand is a local. + if ( + s.pop === true && + s.length.kind === "numLit" && + s.length.value === 0 && + emitter.publishedTypes?.containerGuarded(s.arr.type) !== true + ) + break; + const arr = emitBorrowedInput(emitter, s.arr); + const length = emitter.emitExpr(s.length); + if (s.arr.type.kind !== "array") + throw new InternalCompilerError("llvm emitter bug: arraySetLength on non-array"); + emitter.emitPublishedArrayGuard(arr.name, s.arr.type, s.pop === true ? 2 : 3); + emitter.declare(`declare void @scr_arr_set_len(ptr, double)`); + // Assigning the current length (a reused work array reset to where + // it already is) changes nothing: the runtime's validation accepts + // exactly the value equal to the length (-0 included, NaN never), + // and no element is released. Everything else keeps the call. + const lenPtr = B.tmp(), + len = B.tmp(), + lenNumber = B.tmp(), + same = B.tmp(); + const resize = B.newLabel("arr.len.set"), + done = B.newLabel("arr.len.done"); + B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 1`); + emitter.markMemoryPointer(lenPtr, "array:header"); + B.line( + `${len} = load ${emitter.sizeType}, ptr ${lenPtr}${emitter.fieldAliasAttachment(lenPtr)}${emitter.arrayLengthRange}`, + ); + B.line( + `${lenNumber} = ${sizeToDouble(emitter.sizeType)} ${emitter.sizeType} ${len} to double`, + ); + B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`); + B.condBr(same, done, resize); + B.startBlock(resize); + if (s.pop === true) { + // A discarded pop's `length - 1` on a packed array removes the + // last element inline, then releases it. + const call = B.newLabel("arr.len.call"), + pop = B.newLabel("arr.len.pop"); + const lastNumber = B.tmp(), + dropsLast = B.tmp(); + B.line(`${lastNumber} = fsub double ${lenNumber}, 1.0`); + B.line(`${dropsLast} = fcmp oeq double ${length.name}, ${lastNumber}`); + B.condBr(dropsLast, pop, call); + B.startBlock(pop); + const elem = s.arr.type.elem; + const acc = elemAccess(elem); + const raw = emitDenseArrayPop(emitter, arr.name, acc, call, call); + if (acc === "ref" && isRefCounted(elem)) { + const old = B.tmp(); + B.line(`${old} = inttoptr i64 ${raw} to ptr`); + emitter.releaseHeap(old, elem); + } + B.br(done); + B.startBlock(call); + } + B.line(`call void @scr_arr_set_len(ptr ${arr.name}, double ${length.name})`); + emitter.emitPendingCheck(); + B.br(done); + B.startBlock(done); + break; + } + case "arraySetUndefined": + case "arrayDelete": { + const arr = emitBorrowedInput(emitter, s.arr); + const idx = emitter.emitExpr(s.index); + if (s.arr.type.kind !== "array") + throw new InternalCompilerError(`llvm emitter bug: ${s.kind} on non-array`); + emitter.emitPublishedArrayGuard( + arr.name, + s.arr.type, + s.kind === "arrayDelete" ? 5 : 0, + idx.name, + ); + const fn = s.kind === "arraySetUndefined" ? "scr_arr_set_undefined" : "scr_arr_delete"; + emitter.declare( + `declare ${s.kind === "arrayDelete" ? "zeroext i1" : "void"} @${fn}(ptr, double)`, + ); + if (s.kind === "arrayDelete") + B.line(`call zeroext i1 @${fn}(ptr ${arr.name}, double ${idx.name})`); + else B.line(`call void @${fn}(ptr ${arr.name}, double ${idx.name})`); + break; + } + case "bytesSet": { + // Typed-array element write: same evaluation order as arraySet; + // the value is a scalar (the kind-specific inline path coerces + // JS-exactly), so no ownership moves. Invalid writes are ignored. + // IrBytesElem is static, so never rediscover it through the + // generic runtime switch in a hot loop. + const arr = emitter.emitStableReceiver(s.arr, [s.index, s.value]); + const idx = emitter.emitExpr(s.index); + const v = emitter.emitExpr(s.value); + if (s.arr.type.kind !== "bytes") + throw new InternalCompilerError("llvm emitter bug: bytesSet on non-bytes"); + emitter.emitBytesSet( + s.arr.type.elem, + arr.name, + idx, + v, + s.index, + s.value, + emitter.bytesBounds.has(s), + ); + break; + } + case "fieldSet": + case "recordSet": { + // Evaluation order: obj, then value. New value moved in; the old + // value is released AFTER the field is overwritten (unlink-then- + // release — a release can trigger a cycle collection, which must + // never see a heap edge whose count was already given up). + // Classes and records share the struct layout, so one emission. + const nullable = + s.kind === "fieldSet" ? emitter.nullableFields.get(s.className, s.field) : null; + if (nullable && s.kind === "fieldSet") { + const obj = emitter.emitStableReceiver(s.obj, [s.value]); + // (The guard precedes the value here: the nullable store + // evaluates it itself.) + emitter.emitStoreGuard(obj.name, s); + emitter.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value); + break; + } + if (s.kind === "fieldSet" && emitter.int32Slots.isField(s.className, s.field)) { + const obj = emitter.emitStableReceiver(s.obj, [s.value]); + const v = emitter.emitExpr(s.value); + emitter.emitStoreGuard(obj.name, s); + const { ptr } = emitter.classFieldPtr(obj.name, s.className, s.field); + emitter.storeInt32Field(ptr, v, s.value); + break; + } + if (emitter.emitFieldSelfConcat(s)) break; + const obj = emitter.emitStableReceiver(s.obj, [s.value]); + const v = emitter.emitFieldValue( + s.value, + s.kind === "recordSet" + ? emitter.recordFieldInline(s.shapeId, s.field) + : emitter.inlineUnions.classField(s.className, s.field), + ); + if (s.kind === "fieldSet") emitter.emitStoreGuard(obj.name, s); + else if (emitter.publishedTypes?.recordGuarded(s.shapeId) === true) + emitter.emitPublishedFieldGuard(obj.name, null, s.field); + const { ptr, type, inline } = + s.kind === "fieldSet" + ? emitter.classFieldPtr(obj.name, s.className, s.field) + : emitter.recordFieldPtr(obj.name, s.shapeId, s.field); + // A result-slot entry fills zeroed caller storage, each field once. + if ( + s.kind === "recordSet" && + s.obj.kind === "varRef" && + s.obj.localId === RESULT_DST_ID && + emitter.currentFunctionName.startsWith(RESULT_ENTRY_PREFIX) + ) + emitter.initField(ptr, type, inline, v); + else emitter.replaceField(ptr, type, inline, v); + if ( + s.kind === "fieldSet" && + s.field === "name" && + emitter.classMeta.get(s.className)?.root.def.name === "%Error" + ) { + const present = emitter.classFieldPtr(obj.name, s.className, "%namePresent").ptr; + const enumerable = emitter.classFieldPtr(obj.name, s.className, "%nameEnumerable").ptr; + const wasPresent = B.tmp(), + wasEnumerable = B.tmp(), + isPresent = B.tmp(), + nextEnumerable = B.tmp(); + B.line(`${wasPresent} = load i8, ptr ${present}`); + B.line(`${wasEnumerable} = load i8, ptr ${enumerable}`); + B.line(`${isPresent} = icmp ne i8 ${wasPresent}, 0`); + B.line(`${nextEnumerable} = select i1 ${isPresent}, i8 ${wasEnumerable}, i8 1`); + B.line(`store i8 1, ptr ${present}`); + B.line(`store i8 ${nextEnumerable}, ptr ${enumerable}`); + } + break; + } + case "recordKeyDelete": { + // `delete obj[k]` on a pure index-signature shape: a Map delete on + // the overflow (key and value released; absent keys no-op). + const obj = emitter.emitExpr(s.obj); + const key = emitter.emitExpr(s.key); + if (emitter.publishedTypes?.recordGuarded(s.shapeId) === true) { + // Node: "Cannot delete property 'k' of #" (key known at + // run time only; the guard reports the frozen record generically). + emitter.emitPublishedFieldGuard(obj.name, null, "[key]"); + } + const ovf = emitter.recordOvfPtr(obj.name, s.shapeId); + emitter.declare(`declare zeroext i1 @scr_map_delete_str(ptr, ptr)`); + const t = B.tmp(); + B.line(`${t} = call zeroext i1 @scr_map_delete_str(ptr ${ovf}, ptr ${key.name})`); + break; + } + case "recordKeySet": { + // Dynamic-keyed record write (the C per-shape helper, inline): + // declared keys write through (same-typed — dyn-valued shapes, + // whose writes validate and can throw, stay refused), undeclared + // keys insert/replace in the overflow map. Evaluation order: obj, + // key, value; the write OWNS the value (+1 moves in). + const obj = emitter.emitExpr(s.obj); + const key = emitter.emitExpr(s.key); + const v = emitter.emitExpr(s.value); + if (emitter.publishedTypes?.recordGuarded(s.shapeId) === true) + emitter.emitPublishedFieldGuard(obj.name, null, "[key]"); + if (isRefCounted(v.type)) emitter.toHeap(v); + const shape = emitter.recordShape(s.shapeId); + // Signature-free shapes dispatch over their (one-typed) declared + // fields and TRAP on a miss (scr_record_key_miss — JS would add + // the property, which a monomorphic struct cannot); overflow + // shapes keep the map insert tail. + const iv = shape.indexValue ?? shape.fields[0]?.type; + if (!iv) + throw new InternalCompilerError( + `llvm emitter bug: keyed write on field-free non-overflow shape ${s.shapeId}`, + ); + const vAcc = iv.kind === "f64" ? "f64" : iv.kind === "bool" ? "bool" : "ref"; + if (s.overflowOnly === true) { + // A LITERAL key naming no declared field: a plain overflow + // insert — no field chain. + const ovf = emitter.recordOvfPtr(obj.name, s.shapeId); + emitter.mapSet(ovf, "str", vAcc, key.name, v.name); + break; + } + const join = B.newLabel("rks.j"); + if (iv.kind === "dyn" && shape.indexValue) { + // A dyn-valued shape (the C recordKeySetHelper's dyn arm, + // inline): declared keys VALIDATE the dyn value against the + // field's type first (dynCheck — a mismatched write throws the + // catchable TypeError and leaves the field untouched; JS would + // store anything, the documented divergence); undeclared keys + // insert the dyn value into the overflow map as-is. + emitter.declare(`declare void @scr_dyn_release(ptr)`); + for (const f of shape.fields) { + const hit = B.tmp(); + B.line( + `${hit} = call zeroext i1 ${emitter.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`, + ); + const lh = B.newLabel("rks.h"); + const ln = B.newLabel("rks.n"); + B.condBr(hit, lh, ln); + B.startBlock(lh); + const pathSlot = B.slot(); + B.entryAllocas.push(`${pathSlot} = alloca %ScrDynPath`); + const pp = B.tmp(); + const kp = B.tmp(); + const ip = B.tmp(); + B.line(`${pp} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 0`); + B.line(`store ptr null, ptr ${pp}`); + B.line(`${kp} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 1`); + B.line(`store ptr ${emitter.cstr(f.name)}, ptr ${kp}`); + B.line(`${ip} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 2`); + B.line(`store ${emitter.sizeType} 0, ptr ${ip}`); + const helper = emitter.dyn.dynCheckHelper(f.type); + const fty = emitter.llType(f.type); + const nv = B.tmp(); + B.line( + `${nv} = call ${fty === "i1" ? "zeroext i1" : fty} @${helper}(ptr ${v.name}, ptr ${pathSlot})`, + ); + B.line(`call void @scr_dyn_release(ptr ${v.name})`); + // Mismatched write: TypeError pending, field untouched — the + // statement-level check below unwinds. + const pend = B.tmp(); + for (const line of emitter.pendingTestLines(pend)) B.line(line.trimStart()); + const lw = B.newLabel("rks.w"); + B.condBr(pend, join, lw); + B.startBlock(lw); + const { ptr, type, inline } = emitter.recordFieldPtr(obj.name, s.shapeId, f.name); + if (inline) { + // The slot copies the checked value's bits; nv is released below. + emitter.storeField(ptr, type, nv, true); + emitter.releaseFrame(nv, type); + } else if (isRefCounted(type)) { + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${ptr}`); + emitter.storeField(ptr, type, nv, false); + emitter.releaseHeap(old, type); + } else { + emitter.storeField(ptr, type, nv, false); + } + B.br(join); + B.startBlock(ln); + } + const ovf = emitter.recordOvfPtr(obj.name, s.shapeId); + emitter.mapSet(ovf, "str", vAcc, key.name, v.name); + B.br(join); + B.startBlock(join); + // MAY THROW exactly when a dyn value can validate against a + // declared field (stmts.ts's condition). + if (shape.fields.length > 0) emitter.emitPendingCheck(); + break; + } + for (const f of shape.fields) { + // typeEquals(f.type, iv) — the frontend fences everything else. + const hit = B.tmp(); + B.line( + `${hit} = call zeroext i1 ${emitter.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`, + ); + const lh = B.newLabel("rks.h"); + const ln = B.newLabel("rks.n"); + B.condBr(hit, lh, ln); + B.startBlock(lh); + const { ptr, type, inline } = emitter.recordFieldPtr(obj.name, s.shapeId, f.name); + if (inline) { + // The write owns the moved-in value; the slot copies its bits. + emitter.storeField(ptr, type, v.name, true); + emitter.releaseHeap(v.name, type); + } else if (isRefCounted(type)) { + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${ptr}`); + emitter.storeField(ptr, type, v.name, false); + emitter.releaseHeap(old, type); + } else { + emitter.storeField(ptr, type, v.name, false); + } + B.br(join); + B.startBlock(ln); + } + if (!shape.indexValue) { + // The MISS on a fixed shape: release the moved-in value, throw + // the catchable TypeError naming the key (scr_record_key_miss — + // JS would add the property, the documented divergence). + if (isRefCounted(iv)) emitter.releaseHeap(v.name, iv); + emitter.declare(`declare void @scr_record_key_miss(ptr)`); + B.line(`call void @scr_record_key_miss(ptr ${key.name})`); + B.br(join); + B.startBlock(join); + emitter.emitPendingCheck(); + break; + } + const ovf = emitter.recordOvfPtr(obj.name, s.shapeId); + emitter.mapSet(ovf, "str", vAcc, key.name, v.name); + B.br(join); + B.startBlock(join); + break; + } + case "block": { + if (s.labels === undefined) { + emitter.emitBlock(s.body); + break; + } + // A labeled block: `break lbl` inside branches to the end label. + const le = B.newLabel("blk.e"); + emitter.jumpTargets.push({ + kind: "block", + brkLabel: le, + contLabel: null, + labels: s.labels, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + finallyDepth: emitter.finallyStack.length, + }); + emitter.emitBlock(s.body); + emitter.jumpTargets.pop(); + B.br(le); + B.startBlock(le); + break; + } + case "if": { + const cond = emitter.emitCondition(s.cond); + const trueLabel = B.newLabel("if.t"); + const joinLabel = B.newLabel("if.j"); + const falseLabel = s.else_ ? B.newLabel("if.f") : joinLabel; + B.condBr(cond, trueLabel, falseLabel); + B.startBlock(trueLabel); + emitter.emitBlock(s.then); + B.br(joinLabel); + if (s.else_) { + B.startBlock(falseLabel); + emitter.emitBlock(s.else_); + B.br(joinLabel); + } + B.startBlock(joinLabel); + break; + } + case "while": { + const workerBudget = emitter.workerLoopBudget(s); + const le = B.newLabel("loop.e"); + emitter.emitLoopCopies(s, true, le, (exit) => { + const lc = B.newLabel("loop.c"); + const lb = B.newLabel("loop.b"); + const lp = workerBudget !== null ? B.newLabel("loop.p") : lc; + B.br(lc); + B.startBlock(lc); + B.condBr(emitter.emitCondition(s.cond), lb, exit); + B.startBlock(lb); + emitter.jumpTargets.push({ + kind: "loop", + brkLabel: exit, + contLabel: lp, + labels: s.labels, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + finallyDepth: emitter.finallyStack.length, + }); + emitter.emitBlock(s.body); + emitter.jumpTargets.pop(); + B.br(lp); + if (lp !== lc) { + B.startBlock(lp); + emitter.emitWorkerLoopCheck(workerBudget); + B.br(lc); + } + }); + B.startBlock(le); + break; + } + case "doWhile": { + const workerBudget = emitter.workerLoopBudget(s); + // Body first (runs at least once); continue jumps to the CONDITION. + const le = B.newLabel("loop.e"); + emitter.emitLoopCopies(s, true, le, (exit) => { + const lb = B.newLabel("loop.b"); + const lc = B.newLabel("loop.c"); + B.br(lb); + B.startBlock(lb); + emitter.jumpTargets.push({ + kind: "loop", + brkLabel: exit, + contLabel: lc, + labels: s.labels, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + finallyDepth: emitter.finallyStack.length, + }); + emitter.emitBlock(s.body); + emitter.jumpTargets.pop(); + B.br(lc); + B.startBlock(lc); + emitter.emitWorkerLoopCheck(workerBudget); + B.condBr(emitter.emitCondition(s.cond), lb, exit); + }); + B.startBlock(le); + break; + } + case "for": { + // The init's scope wraps the whole loop (break/continue must NOT + // release it — scopeDepth captured after the push, C parity). + const integerArrayLoop = + emitter.debug === null + ? matchIntegerArrayForLoop(s, emitter.currentLocals, emitter.integerArrayBindings) + : null; + const integerLoop = + integerArrayLoop ?? + (emitter.debug === null ? matchIntegerBytesForLoop(s, emitter.numericLocals) : null); + const countedLoop = + !integerLoop && emitter.countedLoopsEnabled + ? matchIntegerCountedForLoop( + s, + emitter.numericLocals, + emitter.integerRanges, + emitter.unaliasedArrays, + ) + : null; + const window = + countedLoop && emitter.byteWindowsEnabled + ? matchByteWindow( + s, + countedLoop, + emitter.numericLocals, + emitter.captureIds, + emitter.integerRanges, + emitter.byteWindowEntries.get(s) ?? new Set(), + ) + : null; + const denseShape = !emitter.denseLoopsEnabled + ? null + : integerArrayLoop + ? { + counter: integerArrayLoop.localId, + length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" }, + range: { min: 0, max: 4294967294 }, + } + : countedLoop && !countedLoop.guarded + ? countedDenseShape(s, countedLoop) + : null; + const dense = + denseShape && !window + ? matchDenseArrayLoop( + s, + denseShape, + emitter.numericLocals, + emitter.unaliasedArrays, + emitter.denseLoopEntries.get(s) ?? new Set(), + ) + : null; + if (dense) { + const valid = emitDenseLoopGuard(emitter, dense); + const fast = B.newLabel("dense.loop.fast"), + slow = B.newLabel("dense.loop.slow"), + done = B.newLabel("dense.loop.done"); + B.condBr(valid, fast, slow); + emitter.denseLoopsEnabled = false; + try { + B.startBlock(slow); + emitter.emitStmt(s); + B.br(done); + B.startBlock(fast); + emitter.denseLoop = dense; + emitter.emitStmt(s); + B.br(done); + } finally { + emitter.denseLoop = null; + emitter.denseLoopsEnabled = true; + } + B.startBlock(done); + break; + } + if (window) { + const valid = emitByteWindowGuard(emitter, window); + const fast = B.newLabel("bytes.window.fast"), + slow = B.newLabel("bytes.window.slow"), + done = B.newLabel("bytes.window.done"); + B.condBr(valid, fast, slow); + const originalRanges = emitter.integerRanges, + originalBounds = emitter.bytesBounds; + emitter.byteWindowsEnabled = false; + try { + B.startBlock(slow); + emitter.emitStmt(s); + B.br(done); + B.startBlock(fast); + emitter.integerRanges = window.ranges; + emitter.bytesBounds = new Set([...originalBounds, ...window.bounds]); + emitter.emitStmt(s); + B.br(done); + } finally { + emitter.integerRanges = originalRanges; + emitter.bytesBounds = originalBounds; + emitter.byteWindowsEnabled = true; + } + B.startBlock(done); + break; + } + let countedJoin: string | null = null; + if (countedLoop?.guarded) { + let safe = "true"; + if (countedLoop.limitGuarded) { + const bound = emitter.emitExpr(countedLoop.limit); + safe = B.tmp(); + B.line( + `${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`, + ); + } + if (countedLoop.startGuarded) { + // An exact integer of at most 2^53 - 1 in magnitude, and not -0 + // (the bit patterns of the value and its round trip agree). + // NaN and infinities fail the ordered range tests. + const start = emitter.emitExpr(countedLoop.start); + const low = B.tmp(), + high = B.tmp(), + raw = B.tmp(), + wide = B.tmp(), + back = B.tmp(), + bits = B.tmp(), + backBits = B.tmp(), + exact = B.tmp(), + inRange = B.tmp(), + ok = B.tmp(); + B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(-Number.MAX_SAFE_INTEGER)}`); + B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`); + B.line(`${inRange} = and i1 ${low}, ${high}`); + B.line(`${raw} = fptosi double ${start.name} to i64`); + B.line(`${wide} = freeze i64 ${raw}`); + B.line(`${back} = sitofp i64 ${wide} to double`); + B.line(`${bits} = bitcast double ${start.name} to i64`); + B.line(`${backBits} = bitcast double ${back} to i64`); + B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`); + B.line(`${ok} = and i1 ${inRange}, ${exact}`); + if (safe === "true") safe = ok; + else { + const both = B.tmp(); + B.line(`${both} = and i1 ${safe}, ${ok}`); + safe = both; + } + } + const fast = B.newLabel("counted.fast"); + const slow = B.newLabel("counted.slow"); + countedJoin = B.newLabel("counted.done"); + B.condBr(safe, fast, slow); + B.startBlock(slow); + emitter.countedLoopsEnabled = false; + emitter.emitStmt(s); + emitter.countedLoopsEnabled = true; + B.br(countedJoin); + B.startBlock(fast); + } + emitter.scopes.push([]); + let integerSlot: string | null = null; + let countedLimit: string | null = null; + if (integerLoop) { + integerSlot = B.slot(); + B.entryAllocas.push( + `${integerSlot} = alloca ${emitter.sizeType} ; integer induction ${emitter.currentLocals.get(integerLoop.localId)!.name}`, + ); + let start = "0"; + if (integerArrayLoop && s.init?.kind === "varDecl" && s.init.init) { + const value = emitter.emitExpr(s.init.init); + start = B.tmp(); + // The start is 0 or an enclosing array index plus one (at most + // 2^32 - 1): on 64-bit targets the signed conversion is exact + // and avoids x86-64's unsigned 64-bit conversion sequence. + const conversion = emitter.sizeType === "i64" ? "fptosi" : "fptoui"; + B.line(`${start} = ${conversion} double ${value.name} to ${emitter.sizeType}`); + emitter.integerArrayBindings.add(integerLoop.localId); + } + B.line(`store ${emitter.sizeType} ${start}, ptr ${integerSlot}`); + emitter.integerLoopBindings.set(integerLoop.localId, { + slot: integerSlot, + type: emitter.sizeType, + range: + emitter.denseLoop?.counter === integerLoop.localId + ? emitter.denseLoop.range + : { min: 0, max: integerArrayLoop ? 4294967294 : Number.MAX_SAFE_INTEGER - 1 }, + }); + } else if (countedLoop) { + integerSlot = B.slot(); + B.entryAllocas.push( + `${integerSlot} = alloca i64 ; integer induction ${emitter.currentLocals.get(countedLoop.localId)!.name}`, + ); + const startNumber = emitter.emitExpr(countedLoop.start); + const start = B.tmp(); + B.line(`${start} = fptosi double ${startNumber.name} to i64`); + B.line(`store i64 ${start}, ptr ${integerSlot}`); + emitter.integerLoopBindings.set(countedLoop.localId, { + slot: integerSlot, + type: "i64", + signed: true, + range: + emitter.denseLoop?.counter === countedLoop.localId + ? emitter.denseLoop.range + : countedLoop.range, + }); + countedLimit = emitCountedLoopLimit(emitter, countedLoop); + } else if (s.init) { + // A multi-declarator head shares the loop's scope. Emitting its + // IR block as an ordinary block would release captured/ref locals + // before the first condition. + const initializers = s.init.kind === "block" ? s.init.body : [s.init]; + for (const initializer of initializers) emitter.emitStmt(initializer); + } + const le = B.newLabel("loop.e"); + // JS `for (let i ...)`: each iteration gets a FRESH binding holding + // a copy of the previous one (closures made in iteration k keep + // seeing iteration k's value) — only observable, and only emitted, + // when a let variable is captured (boxed). Freshen before the first + // condition and again in the continue-target block before updating. + const initializers = s.init?.kind === "block" ? s.init.body : s.init ? [s.init] : []; + const capturedLets = initializers.flatMap((init) => { + const local = init.kind === "varDecl" ? emitter.currentLocals.get(init.localId) : undefined; + return local?.boxed && local.mutable ? [local] : []; + }); + const freshenBindings = (): void => { + for (const local of capturedLets) { + const slot = `%${mangleLocal(local.id)}`; + const fresh = B.tmp(); + const old = B.tmp(); + B.line( + `${fresh} = ${boxNewCall(emitter.shapeHost, local.type)} ; per-iteration ${local.name}`, + ); + B.line(`${old} = load ptr, ptr ${slot}`); + const val = emitter.boxGet(old, local.type); + emitter.boxSet(fresh, local.type, val); + emitter.declare(`declare void @scr_box_release(ptr)`); + B.line(`call void @scr_box_release(ptr ${old})`); + B.line(`store ptr ${fresh}, ptr ${slot}`); + } + }; + const workerBudget = emitter.workerLoopBudget(s); + freshenBindings(); + emitter.emitLoopCopies(s, capturedLets.length === 0, le, (exit) => { + const lc = B.newLabel("loop.c"); + const lb = B.newLabel("loop.b"); + const lu = + s.update || capturedLets.length > 0 || workerBudget !== null ? B.newLabel("loop.u") : lc; + B.br(lc); + B.startBlock(lc); + if (integerLoop && integerSlot) { + const receiver = emitter.emitStableReceiver(integerLoop.limitReceiver, []); + const lenPtr = B.tmp(); + const len = B.tmp(); + const index = B.tmp(); + const inBounds = B.tmp(); + B.line( + `${lenPtr} = getelementptr inbounds %${integerArrayLoop ? "ScrArr" : "ScrBytes"}, ptr ${receiver.name}, i64 0, i32 1`, + ); + if (integerArrayLoop) emitter.markMemoryPointer(lenPtr, "array:header"); + B.line( + `${len} = load ${emitter.sizeType}, ptr ${lenPtr}${emitter.fieldAliasAttachment(lenPtr)}`, + ); + B.line(`${index} = load ${emitter.sizeType}, ptr ${integerSlot}`); + B.line(`${inBounds} = icmp ult ${emitter.sizeType} ${index}, ${len}`); + B.condBr(inBounds, lb, exit); + } else if (countedLoop && integerSlot && countedLimit) { + const index = B.tmp(); + const inBounds = B.tmp(); + B.line(`${index} = load i64, ptr ${integerSlot}`); + B.line( + `${inBounds} = icmp ${countedLoop.step > 0 ? "slt" : "sgt"} i64 ${index}, ${countedLimit}`, + ); + B.condBr(inBounds, lb, exit); + } else if (s.cond) B.condBr(emitter.emitCondition(s.cond), lb, exit); + else B.br(lb); + B.startBlock(lb); + emitter.jumpTargets.push({ + kind: "loop", + brkLabel: exit, + contLabel: lu, + labels: s.labels, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + finallyDepth: emitter.finallyStack.length, + }); + emitter.emitBlock(s.body); + emitter.jumpTargets.pop(); + B.br(lu); + if (lu !== lc) { + B.startBlock(lu); + emitter.emitWorkerLoopCheck(workerBudget); + freshenBindings(); + if (integerLoop && integerSlot) { + const old = B.tmp(); + const next = B.tmp(); + B.line(`${old} = load ${emitter.sizeType}, ptr ${integerSlot}`); + B.line(`${next} = add nuw ${emitter.sizeType} ${old}, 1`); + B.line(`store ${emitter.sizeType} ${next}, ptr ${integerSlot}`); + } else if (countedLoop && integerSlot) { + const old = B.tmp(); + const next = B.tmp(); + B.line(`${old} = load i64, ptr ${integerSlot}`); + B.line(`${next} = add nsw i64 ${old}, ${countedLoop.step}`); + B.line(`store i64 ${next}, ptr ${integerSlot}`); + } else if (s.update) emitter.emitStmt(s.update); + B.br(lc); + } + }); + B.startBlock(le); + emitter.releaseScope(emitter.scopes.pop()!); + if (integerLoop) emitter.integerLoopBindings.delete(integerLoop.localId); + if (countedLoop) emitter.integerLoopBindings.delete(countedLoop.localId); + if (integerArrayLoop) emitter.integerArrayBindings.delete(integerArrayLoop.localId); + if (countedJoin) { + B.br(countedJoin); + B.startBlock(countedJoin); + } + break; + } + case "forOf": { + // Ascending index loop; the length is re-read every iteration + // (JS-exact — pushes inside the body extend the iteration). The + // iterable temp lives in this statement's frame, so it is released + // when the whole loop ends (and by `return`'s frame sweep). + if (s.iterable.type.kind !== "array") + throw new LlvmUnsupportedError(`forOf:${s.iterable.type.kind}`, s.loc); + const elem = s.iterable.type.elem; + const stored = + s.iterable.kind === "varRef" ? emitter.storedSplits.get(s.iterable.localId) : undefined; + const direct = emitter.streamingSplitsEnabled ? stringSplit(s.iterable) : null; + const snapshot = stored + ? loadSplitSnapshot(emitter, stored) + : direct + ? emitSplitSnapshot(emitter, direct) + : null; + const arr = snapshot ? null : emitter.emitExpr(s.iterable); + const cursor = snapshot ? emitSplitCursor(emitter, snapshot) : null; + const scratch = snapshot ? emitSplitScratch(emitter) : null; + const deferred = snapshot && canDeferSplitPiece(s, emitter.currentLocals.get(s.localId)); + const spanLength = deferred ? B.slot() : null; + if (spanLength) B.entryAllocas.push(`${spanLength} = alloca ${emitter.sizeType}`); + const idxSlot = snapshot ? null : B.slot(); + if (idxSlot) { + B.entryAllocas.push(`${idxSlot} = alloca double`); + B.line(`store double ${f64Lit(0)}, ptr ${idxSlot}`); + } + const lc = B.newLabel("fof.c"); + const lb = B.newLabel("fof.b"); + const lu = B.newLabel("fof.u"); + const le = B.newLabel("fof.e"); + const workerBudget = emitter.workerLoopBudget(s); + B.br(lc); + B.startBlock(lc); + const inBounds = B.tmp(); + let cur: string; + if (snapshot && cursor && scratch) { + cur = spanLength + ? emitSplitSpanNext(emitter, snapshot, cursor, spanLength) + : emitSplitNext(emitter, snapshot, cursor, scratch); + B.line(`${inBounds} = icmp ne ptr ${cur}, null`); + } else { + const i = B.tmp(), + lenPtr = B.tmp(), + rawLen = B.tmp(), + len = B.tmp(); + B.line(`${i} = load double, ptr ${idxSlot}`); + // The inline form of scr_arr_len, like the `length` intrinsic. + B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr!.name}, i32 0, i32 1`); + emitter.markMemoryPointer(lenPtr, "array:header"); + B.line( + `${rawLen} = load ${emitter.sizeType}, ptr ${lenPtr}${emitter.fieldAliasAttachment(lenPtr)}${emitter.arrayLengthRange}`, + ); + B.line( + `${len} = ${sizeToDouble(emitter.sizeType)} ${emitter.sizeType} ${rawLen} to double`, + ); + B.line(`${inBounds} = fcmp olt double ${i}, ${len}`); + cur = i; + } + B.condBr(inBounds, lb, le); + B.startBlock(lb); + emitter.jumpTargets.push({ + kind: "loop", + brkLabel: le, + contLabel: lu, + labels: s.labels, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + finallyDepth: emitter.finallyStack.length, + }); + // The loop variable is a fresh const per iteration: its scope opens + // here, holds the (for ref elements: owned +1) current element, and + // releases it at the end of each iteration. + emitter.scopes.push([]); + const localInfo = emitter.currentLocals.get(s.localId); + const slot = `%${mangleLocal(s.localId)}`; + const borrowedElement = !snapshot && emitter.loopArrayBorrows.has(s); + if (!snapshot) { + const acc = elemAccess(elem); + const getter = borrowedElement ? "scr_arr_borrow_ref" : `scr_arr_get_${acc}`; + cur = emitDenseArrayGet( + emitter, + arr!.name, + { name: cur, type: { kind: "f64" } }, + undefined, + acc, + elem, + getter, + !borrowedElement, + ); + } + if (spanLength) { + const length = B.tmp(); + B.line(`${length} = load ${emitter.sizeType}, ptr ${spanLength}`); + emitter.splitSpans.set(s.localId, { bytes: cur, length, scratch: scratch! }); + cur = "null"; + } + if (localInfo?.boxed) { + // Captured loop variable: a fresh box per iteration, matching the + // fresh const binding. The box takes ownership of a ref element's + // +1 and is released with the iteration's scope. + const box = B.tmp(); + B.line(`${box} = ${boxNewCall(emitter.shapeHost, elem)} ; per-iteration ${localInfo.name}`); + emitter.boxSet(box, elem, cur); + B.line(`store ptr ${box}, ptr ${slot}`); + emitter.scopes[emitter.scopes.length - 1]!.push({ slot, type: elem, boxed: true }); + } else { + B.line(`store ${emitter.llType(elem)} ${cur}, ptr ${slot}`); + if (isRefCounted(elem) && !borrowedElement) + emitter.scopes[emitter.scopes.length - 1]!.push({ slot, type: elem }); + } + emitter.emitStmts(s.body); + if (spanLength) emitter.splitSpans.delete(s.localId); + const endedWithJump = endsWithJump(s.body); + const scope = emitter.scopes.pop()!; + if (!endedWithJump) emitter.releaseScope(scope); + emitter.jumpTargets.pop(); + B.br(lu); + B.startBlock(lu); + emitter.emitWorkerLoopCheck(workerBudget); + if (idxSlot) { + const i2 = B.tmp(), + i3 = B.tmp(); + B.line(`${i2} = load double, ptr ${idxSlot}`); + B.line(`${i3} = fadd double ${i2}, ${f64Lit(1)}`); + B.line(`store double ${i3}, ptr ${idxSlot}`); + } + B.br(lc); + B.startBlock(le); + break; + } + case "switch": + emitter.emitSwitch(s); + break; + case "break": { + // Unlabeled: the innermost loop OR switch (labeled blocks are + // skipped); labeled: the entry carrying the label. + let target: (typeof emitter.jumpTargets)[number] | undefined; + for (let i = emitter.jumpTargets.length - 1; i >= 0; i--) { + const t = emitter.jumpTargets[i]!; + if (s.label !== undefined ? t.labels?.includes(s.label) : t.kind !== "block") { + target = t; + break; + } + } + if (!target) throw new InternalCompilerError("llvm emitter bug: break target not found"); + emitter.emitFinallysForJump(target.finallyDepth, target.frameDepth, target.scopeDepth); + if (!B.isTerminated()) B.terminate(`br label %${target.brkLabel}`); + break; + } + case "continue": { + // Unlabeled: the innermost loop; labeled: the loop carrying the + // label (tsc + the validator guarantee it IS a loop). + let target: (typeof emitter.jumpTargets)[number] | undefined; + for (let i = emitter.jumpTargets.length - 1; i >= 0; i--) { + const t = emitter.jumpTargets[i]!; + if (t.kind === "loop" && (s.label === undefined || t.labels?.includes(s.label))) { + target = t; + break; + } + } + if (!target || target.contLabel === null) + throw new InternalCompilerError("llvm emitter bug: continue target not found"); + emitter.emitFinallysForJump(target.finallyDepth, target.frameDepth, target.scopeDepth); + if (!B.isTerminated()) B.terminate(`br label %${target.contLabel}`); + break; + } + case "return": { + // The value computes FIRST (an SSA temp — finally mutations of + // returned locals cannot change it, Node-exact), then every + // crossed finally runs innermost-first with the frames/scopes/ + // tryStack it sees truncated to its region (its releases already + // ran; a throw inside a copy propagates OUT of the completing + // try, past its own catch), then the function-level releases and + // the actual ret. The parked value owns a synthetic slot-backed + // scope entry during each copy so a throwing finally releases it. + const adopted = + emitter.finallyStack.length > 0 || emitter.tryStack.length > 0 + ? adoptingAwaitOf(s.value) + : null; + if (adopted !== null) { + emitter.emitAdoptingReturn(s, adopted); + break; + } + let v: LlValue | null = null; + const moved = + emitter.finallyStack.length === 0 && !emitter.currentUnionResult + ? emitter.movableReturn(s.value) + : null; + if (s.value !== null && emitter.currentUnionResult) { + // The tag and payload bits leave in registers; a heap box the + // value needed is released with its frame below. + const box = emitter.emitUnionProjection(s.value); + v = { name: emitter.scalarUnionPair(box.name, s.value.type), type: s.value.type }; + } else if (moved !== null) { + // Returning an owned local hands its reference to the caller: no + // retain now and no release of the local below. + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${moved.entry.slot}`); + v = { name: t, type: s.value!.type }; + moved.scope.splice(moved.scope.indexOf(moved.entry), 1); + emitter.releaseForJump(0, 0); + moved.scope.splice(moved.index, 0, moved.entry); + } else if (s.value !== null && emitter.currentBorrowedReturn) { + // A borrowed-return body hands back its walk without a reference + // (it has no try statements, so no finally can intervene). An + // optional element read lends the element its array keeps alive. + const read = emitter.optionalArrayReads.get(s.value); + if (read) { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + emitLocalArrayRead(emitter, { ...read, borrow: true }, slot); + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${slot}`); + v = { name: t, type: s.value.type }; + } else v = emitter.emitReadReceiver(s.value); + } else if (s.value !== null) { + v = emitter.emitExpr(s.value); + if (emitter.currentCoro !== null) emitter.toHeap(v); + else emitter.toFrame(v); + } + // An async generator's `return v` awaits v before any crossed + // finally runs. Past finally regions that turn is taken here and + // the request settles at completion; otherwise the completion's + // own turn is the await (nothing runs in between). + const genSettles = + emitter.currentAsyncGenerator && (s.awaited === true || emitter.finallyStack.length > 0); + // A completion turn that awaits x replaces a return() request's + // already-taken await. + if (emitter.currentAsyncGenerator && !genSettles) { + emitter.declare(`declare void @scr_async_gen_return_awaits()`); + B.line(`call void @scr_async_gen_return_awaits()`); + } + if (emitter.currentAsyncGenerator && s.awaited !== true && emitter.finallyStack.length > 0) { + if (emitter.wasi) emitter.emitWasiSuspend(null); + else { + emitter.declare(`declare void @scr_await_hop()`); + B.line(`call void @scr_await_hop()`); + } + } + if (emitter.finallyStack.length > 0) { + let pretSlot: string | null = null; + if (v !== null && isRefCounted(v.type) && !emitter.currentUnionResult) { + pretSlot = B.slot(); + B.entryAllocas.push(`${pretSlot} = alloca ptr ; pending return (through finally)`); + B.line(`store ptr ${v.name}, ptr ${pretSlot}`); + } + const savedFrames = emitter.frames; + const savedScopes = emitter.scopes; + const savedFinally = emitter.finallyStack; + const savedTry = emitter.tryStack; + for (let i = savedFinally.length - 1; i >= 0 && !B.isTerminated(); i--) { + const fin = savedFinally[i]!; + emitter.releaseForJump(fin.frameDepth, fin.scopeDepth); + emitter.frames = emitter.frames.slice(0, fin.frameDepth); + emitter.scopes = emitter.scopes.slice(0, fin.scopeDepth); + emitter.finallyStack = savedFinally.slice(0, i); + emitter.tryStack = savedTry.slice(0, fin.tryDepth); + if (pretSlot !== null) emitter.scopes.push([{ slot: pretSlot, type: v!.type }]); + emitter.emitBlock(fin.body); + if (pretSlot !== null) emitter.scopes.pop(); + } + if (!B.isTerminated()) emitter.releaseForJump(0, 0); + emitter.frames = savedFrames; + emitter.scopes = savedScopes; + emitter.finallyStack = savedFinally; + emitter.tryStack = savedTry; + } else if (moved === null) { + emitter.releaseForJump(0, 0); + } + if (!B.isTerminated()) { + if (genSettles) emitter.emitAsyncGenSettled(); + if (emitter.currentCoro !== null) { + emitter.emitWasiFulfill(v); + B.terminate(`br label %${emitter.currentCoro.finalLabel}`); + } else if (v === null) B.terminate("ret void"); + else if (emitter.currentInt32Result) + B.terminate(`ret i32 ${emitter.int32Argument(v, s.value!)}`); + else if (emitter.currentUnionResult) B.terminate(`ret ${SCALAR_UNION_RESULT} ${v.name}`); + else B.terminate(`ret ${emitter.llType(s.value!.type)} ${v.name}`); + } + break; + } + case "throw": { + // Evaluate, move ownership into the runtime's exception cell, then + // unwind unconditionally (the innermost try handler, or out of the + // function) — the same release path as return/break/continue. + const v = emitter.emitExpr(s.value); + if (isRefCounted(s.value.type)) emitter.toHeap(v); // the cell takes ownership + emitter.emitThrowValue({ name: v.name, type: s.value.type }); + emitter.emitUnwind(); + break; + } + case "rethrow": { + // Re-raise the saved snapshot (payload retained — the binding + // local releases with its scope) and unwind like `throw`. + const c = emitter.emitExpr({ kind: "varRef", localId: s.localId, type: CAUGHT, loc: s.loc }); + emitter.declare(`declare void @scr_rethrow(ptr)`); + B.line(`call void @scr_rethrow(ptr ${c.name})`); + emitter.emitUnwind(); + break; + } + case "runtimeFence": { + // The deferred JS compile fence: throw a catchable Error naming + // the construct (message) with the SC code stamped on `code`, + // then unwind exactly like `throw`. SCR_ERR_ERROR = 0. + const bytes = Buffer.byteLength(s.message, "utf8"); + emitter.declare(`declare void @scr_throw_error_msg_code(i32, ptr, ${emitter.sizeType}, ptr)`); + B.line( + `call void @scr_throw_error_msg_code(i32 0, ptr ${emitter.cstr(s.message)}, ${emitter.sizeType} ${bytes}, ptr ${emitter.cstr(s.code)})`, + ); + emitter.emitUnwind(); + break; + } + case "tryCatch": + emitter.emitTryCatch(s); + break; + default: { + // Statement coverage is now total (bytesSet closed the set) — + // keep throwing LlvmUnsupportedError for any future IR statement kind. + const rest: never = s; + const k = (rest as IrStmt).kind; + throw new LlvmUnsupportedError(`stmt:${k}`, (rest as IrStmt).loc); + } + } + const frame = emitter.frames.pop()!; + // return/throw already released their frames on the jump path; the + // fall-through releases after them would be dead double-release code. + if ( + s.kind !== "return" && + s.kind !== "throw" && + s.kind !== "rethrow" && + s.kind !== "runtimeFence" + ) { + emitter.releaseTemps(frame); + } +} + +/** try/catch/finally via pending-flag unwinding — stmts.ts's + * emitTryCatch, block-flavored. Entering a try emits NO code: the try + * context is compile-time state (tryStack) redirecting unwinds inside + * the region to a label here. Shape: + * + * { try body } unwinds inside release frames/scopes down to + * this statement's depths, then br the handler + * br after (normal completion skips the handler) + * try.c: (emitted only when some unwind targets it) + * binding = scr_exc_take() (or scr_exc_clear() when bindingless) + * { catch body } + * after: { finally body } normal path + * br try.e + * try.fx: exception path: the pending exception is + * stash = scr_exc_take() STASHED across the finally body so the + * { finally body } body's own pending checks answer for + * scr_rethrow(stash) themselves; a throw inside REPLACES the + * stash (it unwinds through the synthetic + * try.e: scope entry) — JS's semantics exactly + * + * Abrupt completions inside tryBody/catchBody ride the finallyStack: + * returns inline copies at the return site, while break/continue use the + * shared region walk before branching to their resolved target. */ +export function emitTryCatch(emitter: LlEmitter, s: IrStmt & { kind: "tryCatch" }): void { + const B = emitter.B; + const hasCatch = s.catchBody !== null; + const hasFinally = s.finallyBody !== null; + const catchLabel = B.newLabel("try.c"); + const finExcLabel = B.newLabel("try.fx"); + const endLabel = B.newLabel("try.e"); + const afterTryLabel = hasFinally ? B.newLabel("try.f") : endLabel; + + const handler = { + label: hasCatch ? catchLabel : finExcLabel, + used: false, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + }; + if (hasFinally) { + emitter.finallyStack.push({ + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + tryDepth: emitter.tryStack.length, + body: s.finallyBody!, + }); + } + emitter.tryStack.push(handler); + emitter.emitBlock(s.tryBody); + emitter.tryStack.pop(); + B.br(afterTryLabel); // no-op when the try body already terminated + + // Exceptions raised in the CATCH body unwind to the exception-path + // finally (pending stays set through it) when one exists. + const excHandler = { + label: finExcLabel, + used: !hasCatch && handler.used, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + }; + + if (hasCatch && handler.used) { + B.startBlock(catchLabel); + if (hasFinally) emitter.tryStack.push(excHandler); + if (emitter.currentGenerator !== null) { + // Generator bodies: a pending GENRET sentinel (.return(v) + // injected at a yield) is a RETURN completion, not a throw — + // catch must not take it. Re-unwind past this handler (finally + // still runs — the unwind targets the exception-path finally or + // the enclosing context; the depths here equal the handler's). + emitter.declare(`declare zeroext i1 @scr_exc_genret_pending()`); + const gr = B.tmp(); + B.line(`${gr} = call zeroext i1 @scr_exc_genret_pending()`); + const lg = B.newLabel("try.gr"); + const lk = B.newLabel("try.gk"); + B.condBr(gr, lg, lk); + B.startBlock(lg); + emitter.emitUnwind(); + B.startBlock(lk); + } + if (s.catchLocalId !== null) { + // catch (e): the exception MOVES into the binding's snapshot box, + // owned by the catch body's scope (released on every exit — + // normal fall-through, jumps out, and unwinds from the body). + const slot = `%${mangleLocal(s.catchLocalId)}`; + emitter.declare(`declare ptr @scr_exc_take()`); + emitter.emitBlock(s.catchBody!, (scope) => { + const c = B.tmp(); + B.line(`${c} = call ptr @scr_exc_take() ; catch binding`); + if (emitter.currentLocals.get(s.catchLocalId!)?.boxed) { + const box = B.tmp(); + B.line(`${box} = ${boxNewCall(emitter.shapeHost, CAUGHT)} ; captured exception`); + emitter.boxSet(box, CAUGHT, c); + B.line(`store ptr ${box}, ptr ${slot}`); + scope.push({ slot, type: CAUGHT, boxed: true }); + } else { + B.line(`store ptr ${c}, ptr ${slot}`); + scope.push({ slot, type: CAUGHT }); + } + }); + } else { + emitter.declare(`declare void @scr_exc_clear()`); + B.line(`call void @scr_exc_clear() ; catch takes the exception`); + emitter.emitBlock(s.catchBody!); + } + if (hasFinally) emitter.tryStack.pop(); + B.br(afterTryLabel); // the catch's normal completion + } + if (hasFinally) emitter.finallyStack.pop(); + + if (hasFinally) { + B.startBlock(afterTryLabel); + emitter.emitBlock(s.finallyBody!); // normal path + B.br(endLabel); + if (excHandler.used) { + // The pending exception is STASHED across the finally body (a + // ScrCaught snapshot, re-raised after) so the body runs with a + // CLEAN cell — see stmts.ts's exception-path copy. The + // stash rides an alloca slot so a throw inside the body unwinds + // through the synthetic scope entry (replace semantics). + B.startBlock(finExcLabel); + emitter.declare(`declare ptr @scr_exc_take()`); + emitter.declare(`declare void @scr_rethrow(ptr)`); + emitter.declare(`declare void @scr_caught_release(ptr)`); + const stash = B.tmp(); + const stashSlot = B.slot(); + B.entryAllocas.push(`${stashSlot} = alloca ptr ; finally exception stash`); + B.line(`${stash} = call ptr @scr_exc_take() ; stash across finally`); + B.line(`store ptr ${stash}, ptr ${stashSlot}`); + emitter.scopes.push([{ slot: stashSlot, type: CAUGHT }]); + const suppressHandler = + s.suppressFinallyErrors || s.dropFinallyErrors + ? { + label: B.newLabel("try.fs"), + used: false, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + } + : null; + if (suppressHandler) emitter.tryStack.push(suppressHandler); + emitter.emitBlock(s.finallyBody!); + if (suppressHandler) emitter.tryStack.pop(); + emitter.scopes.pop(); // normal completion keeps the stash for the re-raise + B.line(`call void @scr_rethrow(ptr ${stash})`); + B.line(`call void @scr_caught_release(ptr ${stash})`); + emitter.emitUnwind(); + if (suppressHandler?.used) { + B.startBlock(suppressHandler.label); + const cleanup = s.dropFinallyErrors ? "scr_exc_keep_first" : "scr_exc_suppress"; + emitter.declare(`declare void @${cleanup}(ptr)`); + B.line(`call void @${cleanup}(ptr ${stash}) ; consumes stash`); + emitter.emitUnwind(); + } + } + B.startBlock(endLabel); + } else { + B.startBlock(endLabel); + } +} + +/** JS-exact switch: lazily evaluated, arbitrary-expression case tests in + * source order, bodies falling through in source order until a break — + * Literal-only tests can use native dispatch; other tests keep a chain + * of conditional branches. All case bodies + * share ONE scope; because dispatch can jump PAST a varDecl into a later + * case, refcounted/boxed case-body locals are NULL-reset up front and + * the scope-exit releases rely on NULL tolerance. */ +export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): void { + const B = emitter.B; + const discKind = s.disc.type.kind; + if (discKind !== "f64" && discKind !== "string" && discKind !== "bool") { + throw new LlvmUnsupportedError(`switch:${discKind}`, s.loc); + } + // The disc temp lives in the whole statement's frame: for a string + // discriminant it stays alive across every test and body, released + // when the switch statement ends (break lands past this statement's + // frame release — releaseForJump keeps the target's own frame). + const disc = emitter.emitExpr(s.disc); + for (const c of s.cases) { + for (const stmt of c.body) { + if (stmt.kind !== "varDecl") continue; + const local = emitter.currentLocals.get(stmt.localId)!; + if (local.boxed || isRefCounted(local.type)) { + B.line(`store ptr null, ptr %${mangleLocal(local.id)} ; case-scoped ${local.name}`); + } + } + } + const end = B.newLabel("sw.e"); + const caseLabels = s.cases.map(() => B.newLabel("sw.c")); + const defaultIdx = s.cases.findIndex((c) => c.test === null); + const fallback = defaultIdx >= 0 ? caseLabels[defaultIdx]! : end; + if (!emitLiteralSwitch(emitter, disc, s.disc, s.cases, caseLabels, fallback)) { + s.cases.forEach((c, i) => { + if (c.test === null) return; + // Lazy source-order test evaluation (a test after the match never + // runs). Each test's temps release right after its comparison. + emitter.frames.push([]); + const t = emitter.emitExpr(c.test); + const hit = B.tmp(); + if (c.test.kind === "strLit") { + B.line(`${hit} = call zeroext i1 ${emitter.literalEquals(c.test.value)}(ptr ${disc.name})`); + } else if (c.test.type.kind === "string") { + emitter.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); + B.line(`${hit} = call zeroext i1 @sc_str_eq(ptr ${disc.name}, ptr ${t.name})`); + } else if (c.test.type.kind === "bool") { + B.line(`${hit} = icmp eq i1 ${disc.name}, ${t.name}`); + } else { + B.line(`${hit} = fcmp oeq double ${disc.name}, ${t.name}`); + } + emitter.releaseTemps(emitter.frames.pop()!); + const next = B.newLabel("sw.t"); + B.condBr(hit, caseLabels[i]!, next); + B.startBlock(next); + }); + B.br(fallback); + } + + emitter.jumpTargets.push({ + kind: "switch", + brkLabel: end, + contLabel: null, + labels: s.labels, + frameDepth: emitter.frames.length, + scopeDepth: emitter.scopes.length, + finallyDepth: emitter.finallyStack.length, + }); + const scope: LlScopeEntry[] = []; + emitter.scopes.push(scope); + s.cases.forEach((c, i) => { + B.br(caseLabels[i]!); // the previous body's natural fall-through + B.startBlock(caseLabels[i]!); + emitter.emitStmts(c.body); + }); + emitter.jumpTargets.pop(); + emitter.scopes.pop(); + // Natural fall-off of the last body releases the shared scope; a jump + // already released it before jumping. + const lastBody = s.cases[s.cases.length - 1]?.body; + if (!lastBody || !endsWithJump(lastBody)) emitter.releaseScope(scope); + B.br(end); + B.startBlock(end); +} + +/** Emit a loop's condition, body and update through `emitCopy`, twice + * when speculative-integers.ts plans a fast integer copy. Both copies + * share the loop's setup and exit; the ordinary copy comes first so the + * fast copy is only emitted when every resume point exists. */ +export function emitLoopCopies( + emitter: LlEmitter, + loop: Extract, + allowed: boolean, + exit: string, + emitCopy: (exit: string) => void, +): void { + const plan = + allowed && + emitter.speculativeLoopsEnabled && + emitter.speculation === null && + emitter.speculationResume === null + ? planSpeculativeLoop( + loop, + (id) => emitter.speculativeLocal(id), + (id) => emitter.integerLoopBindings.has(id) || emitter.int32Locals.has(id), + (e) => { + const range = emitter.integerRanges.get(e); + return ( + range !== undefined && + range !== null && + range.min >= -Number.MAX_SAFE_INTEGER && + range.max <= Number.MAX_SAFE_INTEGER + ); + }, + (id) => emitter.speculativeLocal(id) || emitter.speculativeInvariantGlobal(id), + ) + : null; + if (!plan) { + emitCopy(exit); + return; + } + const B = emitter.B; + const entries = new Map(); + let ok = "true"; + for (const [id, live] of plan.locals) { + if (!live) continue; + const value = B.tmp(); + B.line(`${value} = load double, ptr ${emitter.binding(id).slot}`); + const [integer, exact] = emitSafeIntegerCheck(emitter, value); + entries.set(id, integer); + if (ok === "true") ok = exact; + else { + const both = B.tmp(); + B.line(`${both} = and i1 ${ok}, ${exact}`); + ok = both; + } + } + const fast = B.newLabel("spec.fast"), + slow = B.newLabel("spec.slow"); + B.condBr(ok, fast, slow); + const resume = new Map(); + for (const s of plan.writes) resume.set(s, { label: B.newLabel("spec.resume"), placed: false }); + B.startBlock(slow); + emitter.speculationResume = resume; + try { + emitCopy(exit); + } finally { + emitter.speculationResume = null; + } + B.startBlock(fast); + if (![...resume.values()].every((r) => r.placed)) { + B.br(slow); + return; + } + for (const id of plan.locals.keys()) { + const shadow = B.slot(); + B.entryAllocas.push( + `${shadow} = alloca i64 ; speculative integer ${emitter.currentLocals.get(id)?.name ?? id}`, + ); + const entry = entries.get(id); + if (entry !== undefined) { + // The same double, now visibly an integer conversion: the double + // phis of the fast copy fold into integer phis. + const exact = B.tmp(); + B.line(`store i64 ${entry}, ptr ${shadow}`); + B.line(`${exact} = sitofp i64 ${entry} to double`); + B.line(`store double ${exact}, ptr ${emitter.binding(id).slot}`); + } else B.line(`store i64 0, ptr ${shadow}`); + emitter.integerLoopBindings.set(id, { + slot: shadow, + type: "i64", + signed: true, + range: SPECULATIVE_RANGE, + }); + } + // A global's store stays in the loop (calls may read it). When nothing + // in the loop can observe it, its double is written once on leaving the + // copy instead, so the loop keeps it in a register as the double copy + // would. + const deferred = new Set(); + // Worker loops also leave through their cancellation poll. + if (plan.observerFree && !emitter.mod.workers) + for (const s of plan.writes) { + const id = speculativeWriteTarget(s)!.localId; + if (!emitter.currentLocals.has(id)) deferred.add(id); + } + emitter.speculation = { + writes: plan.writes, + resume: new Map([...resume].map(([s, r]) => [s, r.label])), + deferred, + }; + try { + if (deferred.size === 0) emitCopy(exit); + else { + const leave = B.newLabel("spec.leave"); + emitCopy(leave); + B.startBlock(leave); + emitter.writeBackSpeculativeGlobals(deferred); + B.br(exit); + } + } finally { + emitter.speculation = null; + for (const id of plan.locals.keys()) emitter.integerLoopBindings.delete(id); + } +} + +/** A speculated write in the fast copy: compute the value as a safe + * integer, store it to the shadow and its exact double to the local. + * Any failed check re-executes the statement in the ordinary copy. */ +export function emitSpeculativeWrite(emitter: LlEmitter, s: IrStmt, resume: string): void { + const B = emitter.B; + const target = speculativeWriteTarget(s)!; + const binding = emitter.integerLoopBindings.get(target.localId)!; + const failed = B.newLabel("spec.bail"); + let bails = false; + emitter.declare("declare i1 @llvm.expect.i1(i1, i1)"); + const bail = (condition: string): void => { + if (condition === "false") return; + bails = true; + const expected = B.tmp(); + B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${condition}, i1 false)`); + const next = B.newLabel("spec.ok"); + B.condBr(expected, failed, next); + B.startBlock(next); + }; + // Heap reads come first, before any check, and release their temporaries + // at once: a bailing path then owns nothing, and reads have no effect + // whose reordering before the arithmetic could be observed. + const reads = new Map(); + if (target.value !== null) { + emitter.frames.push([]); + for (const read of speculativeReads(target.value)) reads.set(read, emitter.emitExpr(read)); + emitter.releaseTemps(emitter.frames.pop()!); + } + emitter.frames.push([]); + let result: string; + if (target.value === null) { + const old = B.tmp(); + result = B.tmp(); + B.line(`${old} = load i64, ptr ${binding.slot}`); + B.line(`${result} = ${target.op === "+" ? "add" : "sub"} nsw i64 ${old}, 1`); + const biased = B.tmp(), + outside = B.tmp(); + B.line(`${biased} = add i64 ${result}, ${Number.MAX_SAFE_INTEGER}`); + B.line(`${outside} = icmp ugt i64 ${biased}, ${2 * Number.MAX_SAFE_INTEGER}`); + bail(outside); + } else result = emitSpeculativeInteger(emitter, target.value, bail, reads); + // Speculable values read only numbers past this point. + if (emitter.frames.pop()!.length > 0) + throw new InternalCompilerError("llvm emitter bug: speculative write owns a temporary"); + B.line(`store i64 ${result}, ptr ${binding.slot}`); + const deferred = emitter.speculation!.deferred; + if (!deferred.has(target.localId)) { + const number = B.tmp(); + B.line(`${number} = sitofp i64 ${result} to double`); + B.line(`store double ${number}, ptr ${emitter.binding(target.localId).slot}`); + } + if (!bails) return; + const done = B.newLabel("spec.done"); + B.br(done); + B.startBlock(failed); + emitter.writeBackSpeculativeGlobals(deferred); + B.br(resume); + B.startBlock(done); +} + +/** + * Lower canonical `target = target + suffix` after evaluating the old + * left side and suffix in JavaScript order. The snapshot stays owned by + * the statement frame while the destination relinquishes its CURRENT + * value, making the snapshot unique unless a real observable alias exists. + */ +export function emitStringSelfConcatAssign( + emitter: LlEmitter, + localId: string, + left: IrExpr, + suffix: IrExpr, + retainForYield: boolean, +): LlValue { + const moved = emitter.emitMovedSelfConcat(localId, left, suffix, retainForYield); + if (moved) return moved; + const snapshot = emitter.emitExpr(left); + // A suffix of several parts, or a number, appends each operand to the + // snapshot directly (in place once the binding lets go of it) instead + // of first building the suffix as a separate string. + const parts = stringParts(suffix); + const mixed = parts.length > 1 || numberPart(parts[0]!) !== null; + const charCode = mixed ? null : emitter.emitSingleCharCode(suffix); + const inputs = mixed ? emitConcatInputs(emitter, parts) : []; + // The concat only reads the suffix; relinquishing the binding below + // runs no code that could rebind a borrowed local. + const right = mixed || charCode !== null ? null : emitStringInputs(emitter, [suffix])[0]!; + const b = emitter.binding(localId); + const B = emitter.B; + if (b.kind === "boxed") { + // set_ref(NULL) unlinks then releases the binding's post-suffix value. + emitter.boxSet(emitter.loadBox(b.slot), b.type, "null"); + } else { + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${b.slot}`); + B.line(`store ptr null, ptr ${b.slot}`); + if (b.kind === "global") emitter.releaseHeap(old, b.type); + else emitter.releaseFrame(old, b.type); + } + let raw: string; + if (charCode !== null) { + // The snapshot moves into the consuming character append. + emitter.toFrame(snapshot); + emitter.declare(`declare ptr @scr_str_concat_move_char_code(ptr, double)`); + raw = B.tmp(); + B.line( + `${raw} = call ptr @scr_str_concat_move_char_code(ptr ${snapshot.name}, double ${charCode.name})`, + ); + } else if (right === null) { + raw = emitMixedConcat(emitter, snapshot.name, inputs); + } else { + emitter.declare(`declare ptr @scr_str_concat(ptr, ptr)`); + raw = B.tmp(); + B.line(`${raw} = call ptr @scr_str_concat(ptr ${snapshot.name}, ptr ${right.name})`); + } + const result = emitter.own({ name: raw, type: left.type }); + if (retainForYield) { + const stored = + b.kind === "local" + ? emitter.retainFrame(result.name, result.type) + : emitter.retainHeap(result.name, result.type); + if (b.kind === "boxed") emitter.boxSet(emitter.loadBox(b.slot), b.type, stored); + else B.line(`store ptr ${stored}, ptr ${b.slot}`); + } else { + if (b.kind === "local") emitter.toFrame(result); + else emitter.toHeap(result); + if (b.kind === "boxed") emitter.boxSet(emitter.loadBox(b.slot), b.type, result.name); + else B.line(`store ptr ${result.name}, ptr ${b.slot}`); + } + return result; +} diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e86b447d9..64a535027 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,9 +1,7 @@ import { jsTruthy } from "./jsval.js"; -import { type ArraySearchKind, emitArraySearchHelpers } from "./array-search.js"; +import { type ArraySearchKind } from "./array-search.js"; import { emitStringInputs } from "./string-lifetimes.js"; -import { emitLlvmLayouts } from "./layouts.js"; -import { emitThreadSlotsIntrinsic, threadSlotsBlockDefinitions } from "./thread-slots.js"; -import { compileRegexMatcher, type RegexMatcherCode } from "./regex-matchers.js"; +import { emitThreadSlotsIntrinsic } from "./thread-slots.js"; import { CYC_BUF, CYC_HDR, @@ -13,18 +11,16 @@ import { WEAK_KEYS_GATE, } from "./alloc.js"; import { youngNurseryRequested } from "../young-nursery.js"; -import { hasLoneSurrogate, llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; +import { llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; import { createHash } from "node:crypto"; import { endsWithJump, matchFieldSelfConcat, - matchStringSelfConcat, readsArraySlot, streamTypedRefEligible, undefinedArmTag, } from "../../ir/analysis.js"; -import { emitLibraryIdentityLines } from "../library-identity-markers.js"; import { loopTestsEveryIteration } from "../loop-polls.js"; import type { IrBytesElem, @@ -42,10 +38,7 @@ import type { SrcLoc, } from "../../ir/ir.js"; import { - CAUGHT, - ffiCallbackType, isDynTypedRefType, - isFfiContextParam, isRefCounted, isUnitType, POINTER_KINDS, @@ -56,77 +49,34 @@ import { RUNTIME_STREAM_CLASSES, typeEquals, typeKey, - STRING, VOID, classJsName, } from "../../ir/ir.js"; import { moduleRuntimeFeatures, moduleUsesMockTimers } from "../../ir/runtime-features.js"; import { captureFreeFunctions, observesStacks } from "../stack-observations.js"; +import { type DenseArrayLoop } from "./dense-array-loops.js"; +import { findInitializedByteLoopBindings } from "./byte-windows.js"; import { - matchIntegerArrayForLoop, - matchIntegerBytesForLoop, - matchIntegerCountedForLoop, -} from "../../ir/integer-loops.js"; -import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js"; -import { - countedDenseShape, - emitDenseLoopGuard, - matchDenseArrayLoop, - type DenseArrayLoop, -} from "./dense-array-loops.js"; -import { emitCountedLoopLimit } from "./counted-loops.js"; -import { - emitSafeIntegerCheck, - emitSpeculativeInteger, - planSpeculativeLoop, - speculativeReads, - speculativeWriteTarget, - SPECULATIVE_RANGE, -} from "./speculative-integers.js"; -import { emitLiteralSwitch } from "./switch-dispatch.js"; -import { findBytesBounds } from "./bytes-bounds.js"; -import { - emitByteWindowGuard, - findInitializedByteLoopBindings, - matchByteWindow, -} from "./byte-windows.js"; -import { - canDeferSplitPiece, - emitSplitSpanNext, materializeSplitPiece, type SplitSpan, - emitSplitCursor, - emitSplitNext, - emitSplitScratch, - emitSplitSnapshot, - findPrivateSplitLocals, - loadSplitSnapshot, - storeSplitSnapshot, - stringSplit, type StoredSplitSnapshot, type StringSplit, } from "./split-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; -import { RESULT_DST_ID, RESULT_ENTRY_PREFIX, addRecordResultEntries } from "./record-results.js"; +import { addRecordResultEntries } from "./record-results.js"; import { specializeCallbackCalls } from "../../ir/callback-specialization.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; -import { - analyzeIntegerRanges, - INT32_RANGE, - type IntegerRange, - type IntegerRanges, -} from "../../ir/integer-ranges.js"; +import { INT32_RANGE, type IntegerRange, type IntegerRanges } from "../../ir/integer-ranges.js"; import { unshareNodes } from "../../ir/unshare.js"; import { copyStableCaptures } from "../../ir/capture-values.js"; import { resolveSelfCaptures } from "../../ir/self-captures.js"; import { analyzeInt32Slots, dynamicFieldStores, type Int32Slots } from "../../ir/int32-slots.js"; -import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; +import { analyzeFinalFields, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; import { dropUnwrittenPropertyBags } from "../../ir/property-bags.js"; import { freezeInitOnlyGlobals } from "../../ir/init-only-globals.js"; import { hoistFinalFieldReads } from "../../ir/field-values.js"; -import { findIntegerViews } from "./integer-views.js"; import { findInt32Abis, INT32_ABI_SUFFIX, @@ -134,22 +84,10 @@ import { SCALAR_UNION_RESULT, type Int32Abi, } from "./int32-abi.js"; -import { findInt32Locals, type Int32Local } from "./int32-locals.js"; -import { - emitIntegerClone, - emitIntegerDispatcher, - findIntegerClones, - NO_INTEGER_CLONES, - type IntegerClones, -} from "./integer-clones.js"; +import { type Int32Local } from "./int32-locals.js"; +import { findIntegerClones, NO_INTEGER_CLONES, type IntegerClones } from "./integer-clones.js"; import { exactInteger, narrowInteger } from "./integer-values.js"; -import { - conditionalLoopWrites, - findInitializerBindings, - initializerLocalCandidates, - withInitializerBindings, - withInitializerLocals, -} from "../../ir/initializer-bindings.js"; +import { findInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { allocateFfiCallbackAdapters, @@ -157,47 +95,28 @@ import { hasRetainedFfiCallback, type FfiCallbackAdapter, } from "../ffi-callbacks.js"; -import { RUNTIME_ABI_MARKER } from "../runtime-abi.js"; import { computeMayThrow } from "../may-throw.js"; import { - mangleArgPack, - mangleAsyncAdoptCall, - mangleAsyncAwaitCall, mangleAsyncInlineAwaitCall, mangleAsyncSpawn, mangleBorrowedFunction, mangleClassObj, - mangleClassStruct, mangleFnClosure, mangleFunction, - mangleGenDrop, - mangleGenSourceClose, mangleGenSpawn, mangleGlobal, mangleLocal, mangleRecordStruct, - mangleTrampoline, mangleWrapper, mangleBorrowingWrapper, - mangleVtInstance, } from "../mangle.js"; import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js"; -import { canStackUnion, emitStackUnion, findLocalStackUnions } from "./stack-unions.js"; import { - emitStackObject, - findLocalStackObjects, - findReassignedStackObjects, - stackObjectFields, stackObjectStruct, type StackObjectField, type StackObjectSource, } from "./stack-objects.js"; import { - allocateLocalUnion, - emitScalarOptional, - findLocalUnionStorage, - isScalarOptionalSource, - storeLocalUnion, type LocalUnionStorageProof, type LocalUnionStorage, type UnionSources, @@ -212,63 +131,30 @@ import { storeInlineUnion, unionBoxBits, } from "./inline-unions.js"; -import { - emitLocalArrayRead, - emitProjectedArrayRead, - emitBorrowedArrayRead, - findLocalArrayReads, - findCallArrayReads, - OptionalArrayReads, - type LocalArrayRead, -} from "./local-array-reads.js"; -import { CheckedNarrows, emitCheckedNarrowReceiver } from "./checked-narrows.js"; -import { - emitDenseArrayGet, - emitDenseArrayPop, - emitDenseArraySet, - emitDenseSlotUnlink, -} from "./dense-array-access.js"; -import { - emitStackMapRead, - findMapReadLifetimes, - findReadNumberMapWrites, - matchMapRead, - type MapReadLifetimes, -} from "./map-read-lifetimes.js"; -import { findMapEntryFusions, type MapEntryFusion } from "./map-entry-fusion.js"; +import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js"; +import { CheckedNarrows } from "./checked-narrows.js"; +import { emitDenseArraySet, emitDenseSlotUnlink } from "./dense-array-access.js"; +import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js"; +import { type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; -import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; -import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; +import { emitBorrowedInputs } from "./borrowed-inputs.js"; +import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; import { lastUseRead } from "./local-extents.js"; import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; import { ReferenceWrites, type Writes } from "./reference-writes.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; -import { dynHoleRead, findLoopArrayBorrows } from "./loop-array-borrows.js"; import { findAppendedArrayLiterals } from "./appended-array-literals.js"; import { AmbientReceiverReaders, findConstantCallbacks } from "./constant-callbacks.js"; import { - findScalarStringSlices, - findScratchStringSlices, findLentStringParameters, - emitStringSliceSnapshot, type LentStringParameters, type StringSliceSnapshot, } from "./string-slices.js"; import { LazyCaptures } from "./lazy-captures.js"; import { StackCaptures } from "./stack-captures.js"; -import { - ABSENT_FIELD_PAYLOAD, - emitFieldAbsentTest, - exactIndexLines, - f64Lit, - ffiNativeTypeLl, - ffiNativeParamLl, - ffiNativeReturnLl, - llvmCommentText, - sizeToDouble, -} from "./common.js"; +import { emitFieldAbsentTest, f64Lit } from "./common.js"; import { ffiExtendsNarrowIntegers } from "../targets.js"; import { emitLiteralExpr, @@ -278,7 +164,7 @@ import { emitRecordExpr, } from "./expr-primitives.js"; import { emitControlExpr } from "./expr-control.js"; -import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js"; +import { emitCallExpr } from "./expr-calls.js"; import { emitDynamicExpr } from "./expr-dynamic.js"; import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js"; import { PublishWalkers } from "./publish.js"; @@ -321,7 +207,6 @@ import { emitArrayCopyLoop, emitStrIntrinsic, emitArrIntrinsic, - emitDiscardedSplice, wrapNullable, emitMapNew, mapSet, @@ -371,8 +256,6 @@ import { import { buildClassGraph, classMembershipIntervals, - emitClassMembershipHelper, - classEnvironmentIndex, classFieldIndex, classStructSym, programLifetimeClass, @@ -385,13 +268,6 @@ import { LlvmUnsupportedError } from "./unsupported.js"; import { LlWalkers } from "./walkers.js"; import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js"; import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js"; -import { literalCapWord, literalCapWord32 } from "./string-key-hash.js"; -import { - MAP_ITER_DECL, - NUMBER_MAP_ENTRY_DECL, - STRING_MAP_ENTRY_DECL, -} from "./map-number-lookup.js"; -import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js"; import { emitConcatInputs, emitMixedConcat, @@ -404,30 +280,60 @@ import { boxNewCall, boxReleaseSym, computeTraced, - elemAccess, emitInlineRcHelpers, emitNullableRetainWrappers, emitShapeUnionHelpers, FN_ATTRS, inlineRcDecls, type InlineRcMode, - limitInlineDynReleases, llFieldType, mapFlagsOffset, type MapKeyAccess, releaseSym, retainSym, traceArg, - typedRefConstructor, vAdapters, } from "./shapes.js"; import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js"; -import { INLINE_LITERAL_BYTES, literalEqualityDefinition } from "./string-literal-equality.js"; -import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js"; +import { INLINE_LITERAL_BYTES } from "./string-literal-equality.js"; +import { + emitFfiCallbackDefs, + emitLibDefs, + emitModuleParts, + emitParts, + helperDefs, +} from "./emit-module.js"; +import { + emitLoopCopies, + emitSpeculativeWrite, + emitStmtBody, + emitStringSelfConcatAssign, + emitSwitch, + emitTryCatch, +} from "./emit-statements.js"; +import { + emitArgPackAndTrampolinePrologue, + emitAsyncAwaitCall, + emitAsyncScaffolding, + emitFunction, + emitGenScaffolding, + emitWasiFulfill, + fiberlessAsyncFunctions, + parkingFunctions, +} from "./emit-function.js"; +import { + analyzeBorrowedReturns, + canBorrowCallArgument, + canBorrowReceiver, + canStackReceiver, + emitReadReceiver, + fieldAliasMetadata, + isStackUnionSource, +} from "./receivers.js"; export { LlvmUnsupportedError } from "./unsupported.js"; -interface LlArgPackAndTrampolinePrologue { +export interface LlArgPackAndTrampolinePrologue { definitions: string[]; pack: string; lifted: boolean; @@ -438,7 +344,7 @@ interface LlArgPackAndTrampolinePrologue { argPackLines: string[]; } -function ffiCallbackDummyLl(callback: IrFfiCallbackParam["callback"]): string { +export function ffiCallbackDummyLl(callback: IrFfiCallbackParam["callback"]): string { switch (callback.returns) { case "i64": case "u64": @@ -464,7 +370,7 @@ function ffiCallbackDummyLl(callback: IrFfiCallbackParam["callback"]): string { } } -interface LlScopeEntry { +export interface LlScopeEntry { slot: string; type: IrType; boxed?: boolean; @@ -568,7 +474,7 @@ export function emitLlvmModuleSource( return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); } -function llStrBytes(text: string): string { +export function llStrBytes(text: string): string { return llBytes(wtf8Bytes(text)); } @@ -576,7 +482,7 @@ const NO_BORROWED: ReadonlySet = new Set(); /** Runtime entry points (and await-call entries) that may park the running * execution context, as they appear in emitted function text. */ -const SUSPENDS = +export const SUSPENDS = /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_)|sc_a[wd]_)/; /** The suspensions of a fiber body's own awaits, which its stackless * coroutine form suspends itself at instead. */ @@ -587,7 +493,7 @@ const COROUTINE_SUSPENDS = /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_(?!self\b|promise\b))|sc_a[wd]_)/; /** Captured bindings (boxes a body declares or receives) from which a * function compiles without LLVM optimization (see boxSlotCount). */ -const UNOPTIMIZED_BOX_SLOTS = 512; +export const UNOPTIMIZED_BOX_SLOTS = 512; /** ScrExcKind tags of a generator slot holding a number or a boolean. */ const SCR_EXC_F64 = 1; const SCR_EXC_BOOL = 2; @@ -605,7 +511,7 @@ const SCR_EXC_BOOL = 2; * optnone bodies, as the native helper's does. Each use reloads its box * pointer and code generation stays linear. Semantics, boxes and * environments are unchanged; only that body's machine code is slower. */ -function boxSlotCount(fn: IrFunction): number { +export function boxSlotCount(fn: IrFunction): number { const captured = new Set(); for (const capture of fn.captures ?? []) captured.add(capture.localId); for (const capture of fn.classCaptures ?? []) captured.add(capture.localId); @@ -649,26 +555,26 @@ function mentionsLocal(e: IrExpr, localId: string): boolean { export class LlEmitter { localArrayReads = new Map(); - private loopArrayBorrows: ReadonlySet = new Set(); - private scalarStringSlices = new Map(); + loopArrayBorrows: ReadonlySet = new Set(); + scalarStringSlices = new Map(); readonly stringSlices = new Map(); /** Slices written into stack buffers (findScratchStringSlices). */ scratchStringSlices: ReadonlySet = new Set(); /** String parameters a caller may lend scratch slices to. */ - private lentStringParameters: LentStringParameters = new Map(); - private localStackUnions = new Map(); - private localStackObjects = new Map(); - private reassignedStackObjects = new Set(); + lentStringParameters: LentStringParameters = new Map(); + localStackUnions = new Map(); + localStackObjects = new Map(); + reassignedStackObjects = new Set(); /** The binding slot of each reassigned stack-object local. */ - private stackObjectSlots = new Map(); + stackObjectSlots = new Map(); /** Entry-block pointers to the reference fields of each frame object * slot (stack-objects.ts stackObjectFields). */ stackObjectFieldPtrs = new Map(); - private localUnionStorageProofs = new Map(); - private localUnionStorage = new Map(); + localUnionStorageProofs = new Map(); + localUnionStorage = new Map(); integerArrayBindings = new Set(); - private readonly fieldAliasTags = new Map(); - private readonly fieldPointerTags = new Map(); + readonly fieldAliasTags = new Map(); + readonly fieldPointerTags = new Map(); /** Native class fields with different property names cannot occupy the * same slot of one object. Inherited views of the same property share a @@ -726,22 +632,13 @@ export class LlEmitter { const tag = this.fieldPointerTags.get(ptr); return tag === undefined ? "" : `, !tbaa !${tag}`; } - - private fieldAliasMetadata(): string[] { - if (this.fieldAliasTags.size === 0) return []; - const lines = ['!0 = !{!"scriptc native fields"}']; - for (const tag of this.fieldAliasTags.values()) { - lines.push( - `!${tag - 1} = !{!"field.${tag}", !0, i64 0}`, - `!${tag} = !{!${tag - 1}, !${tag - 1}, i64 0}`, - ); - } - return lines; + fieldAliasMetadata(): string[] { + return fieldAliasMetadata(this); } - private readonly debug: LlvmDebugInfo | null; + readonly debug: LlvmDebugInfo | null; /** Projection-only scalar objects may live in frame storage (stack-objects.ts). */ readonly stackObjects: boolean; - private debugScope: string | null = null; + debugScope: string | null = null; readonly sizeType: "i32" | "i64"; readonly ffiExtendNarrowIntegers: boolean; readonly cycleBufOffset: number; @@ -760,48 +657,48 @@ export class LlEmitter { * generators' conditions; library and Windows builds keep fibers. */ private stacklessAsyncAllowed = false; /** The async functions selectStacklessAsync lowered as coroutines. */ - private readonly stacklessAsync = new Set(); + readonly stacklessAsync = new Set(); /** Some function in the module is lowered as a coroutine. */ - private usesCoroutines = false; + usesCoroutines = false; /** The target lacks a rounding instruction at its baseline CPU (x86-64 * before SSE4.1), so `llvm.trunc` would lower to a libm call: emit * truncation inline through an integer round trip instead. */ readonly inlineTrunc: boolean; /** ELF worker executables give thread-locals the executable TLS models. */ - private readonly executableTls: boolean; + readonly executableTls: boolean; /** Source loops enclosing the statement being emitted in the current * function body. */ - private loopDepth = 0; + loopDepth = 0; /** Darwin worker executables read the stack guard from a pthread * thread-specific-data slot off the thread pointer (scr_runtime.h's * SCR_STACK_GUARD_TSD): the architecture names the access sequence. */ private readonly stackGuardTsd: "aarch64" | "x86_64" | null; - private readonly emitLibraryIdentity: boolean; - private readonly runtimeAbiMarker: boolean; - private readonly detachedExit: boolean; + readonly emitLibraryIdentity: boolean; + readonly runtimeAbiMarker: boolean; + readonly detachedExit: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — * first-use order, the runtime ABI’s determinism discipline. */ - private readonly literals = new Map(); + readonly literals = new Map(); /** Literal equality helpers: literal text → helper symbol (literalEquals). */ - private readonly literalEqualities = new Map(); + readonly literalEqualities = new Map(); /** Interned bigint literals: hex magnitude with sign → { symbol, value }. */ - private readonly bigintLiterals = new Map(); + readonly bigintLiterals = new Map(); /** Symbols of interned constant data, named by content (contentSymbol). */ private readonly contentSymbols = new Set(); /** Interned unit-armed union instances: "unionId:tag" → symbol — one * immortal (rc == SIZE_MAX) static per (union, unit tag). * RC entry points and the collector skip immortals. */ - private readonly unitInstances = new Map(); + readonly unitInstances = new Map(); /** unionId → symbol of the union's immortal ABSENT field-slot instance. */ - private readonly absentInstances = new Map(); + readonly absentInstances = new Map(); /** The zeroed box an inline store reads for a NULL union dummy. */ - private needsUnionNone = false; + needsUnionNone = false; /** unionId → symbol of a flag set once a checked-dynamic cast caches one * of the union's boxes. A cached box is refreshed in place, possibly with * a tag that another union numbers apart, so it is never shared. */ - private readonly retypeGuards = new Map(); + readonly retypeGuards = new Map(); /** The nullable-union ABSENT sentinel was referenced (absentInstanceRef). */ - private needsNullableAbsent = false; + needsNullableAbsent = false; private readonly immortalValues = new Set(); /** Regex literal templates: "/" → { symbol, interned * source/flags literal refs } — one immortal ScrRegex template per distinct @@ -819,9 +716,9 @@ export class LlEmitter { readonly templateStringsInstances = new Map(); /** Interned NUL-terminated C-string constants (scr_jb_puts labels, the * stringify indent text): UTF-8 text → { symbol, byte length }. */ - private readonly cstrs = new Map(); + readonly cstrs = new Map(); /** Interned ScrMapOps descriptors (scr_map.h): initializer → symbol. */ - private readonly mapOpsConsts = new Map(); + readonly mapOpsConsts = new Map(); /** Type-directed walker functions (JSON serializers, the indent * rewriter, union ToString/join) — interned per typeKey/unionId, defs * flushed with the shape helpers. */ @@ -832,17 +729,17 @@ export class LlEmitter { /** Explicit callback boundary for standalone layout and walker stages. */ readonly shapeHost: DynHost; /** External declarations, in first-use order. */ - private readonly decls = new Set(); + readonly decls = new Set(); /** Declared functions referenced as values: each needs an env-signature * wrapper + an interned immortal closure (so `f === f` holds). */ readonly fnValues = new Set(); - private needsOom = false; + needsOom = false; needsBadTag = false; needsBadKey = false; - private needsRetainBox = false; + needsRetainBox = false; /** Inline RC fast-path helpers requested by call sites (shapes.ts); null * when inline RC is off. */ - private readonly rcHelpers: Set | null; + readonly rcHelpers: Set | null; private readonly rcMode: InlineRcMode | undefined; private readonly rcHelpersNoinline: boolean; @@ -851,11 +748,11 @@ export class LlEmitter { appendedArrayLiterals: ReadonlySet = new Set(); /** Emitted LLVM text of every IR function body, by function name: the * fiberless-async analysis scans it for suspension entry points. */ - private readonly fnDefText = new Map(); + readonly fnDefText = new Map(); readonly referenceEffects: ReferenceEffects; /** Field-sensitive reference writes (null in debug builds). */ referenceWrites: ReferenceWrites | null = null; - private readonly borrowedReturnKeyCache = new Map(); + readonly borrowedReturnKeyCache = new Map(); /** The statement list being emitted and the current index, for blocks * whose bindings end with the list (emitBlock and function bodies). */ private stmtCursor: { stmts: IrStmt[]; index: number } | null = null; @@ -873,63 +770,63 @@ export class LlEmitter { currentConstantCallbacks: ReadonlyMap = new Map(); readonly stackCallbacks: StackCallbacks; readonly receiverReaders: AmbientReceiverReaders; - private readonly stackCaptures: StackCaptures; - private readonly lazyCaptures: LazyCaptures; + readonly stackCaptures: StackCaptures; + readonly lazyCaptures: LazyCaptures; /** Unchanged captured parameters of the current function: each maps to * the slot caching its box, NULL until the first closure needs it. */ - private readonly lazyCaptureBoxes = new Map(); - private readonly lazyStackBoxes = new Map(); - private borrowedParameters = new Set(); + readonly lazyCaptureBoxes = new Map(); + readonly lazyStackBoxes = new Map(); + borrowedParameters = new Set(); /** Captures of the current closure body that its environment holds * unchanged for the closure's whole life (LazyCaptures.stableCaptures). */ - private stableCaptureIds: ReadonlySet = new Set(); + stableCaptureIds: ReadonlySet = new Set(); /** Parameters proven projection-only: callers may pass stack boxes. */ - private projectedParameters = new Set(); - private stableCallBindings: ReadonlySet = new Set(); + projectedParameters = new Set(); + stableCallBindings: ReadonlySet = new Set(); /** Locals and rebound parameters holding borrowed pointers for their * whole lifetime (walk-borrows.ts), per function and for the current one. */ - private readonly walkBorrowsByFunction = new Map>(); - private walkBorrows: ReadonlySet = new Set(); + readonly walkBorrowsByFunction = new Map>(); + walkBorrows: ReadonlySet = new Set(); /** Functions whose borrowing body returns its result without a reference * (analyzeBorrowedReturns), and whether the current body is one. */ readonly borrowedReturns = new Set(); /** Rebound parameters that keep the borrowed convention with an owner * slot (rebound-parameters.ts), per function, and the current owner slots. */ - private readonly reboundByFunction = new Map>(); - private readonly reboundOwnerSlots = new Map(); - private currentBorrowedReturn = false; + readonly reboundByFunction = new Map>(); + readonly reboundOwnerSlots = new Map(); + currentBorrowedReturn = false; /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); /** C-ABI callback trampolines and (for raw/no-userdata callbacks) their * distinct call-scoped TLS closure slots. */ - private readonly ffiCallbackAdapters: Map; + readonly ffiCallbackAdapters: Map; /** Module-level constant consulted per ffiCall: with a retained * descriptor anywhere in the manifest, every native call is a * pending-exception checkpoint (may-throw derives the same fact from * the same helper). */ readonly ffiHasRetainedCallback: boolean; - private readonly ffiHasForeignCallback: boolean; + readonly ffiHasForeignCallback: boolean; private readonly globalTypes = new Map(); /** Module constants that keep one value once initialized * (immutability.ts stableGlobals). */ - private stableGlobals: ReadonlySet = new Set(); + stableGlobals: ReadonlySet = new Set(); private readonly tdzGlobals = new Map(); readonly constantNumericTables: ReadonlyMap; /** Constant-key reads on checked-dynamic objects, each with its own * zero-initialized ScrDynReadIc (scr_runtime.h). */ - private dynReadIcSites = 0; + dynReadIcSites = 0; /** ThreadSlots blocks by family name (thread-slots.ts). */ - private readonly threadSlotsBlocks = new Map(); + readonly threadSlotsBlocks = new Map(); /** May-throw analysis (computeMayThrow): pending * checks are emitted only after calls that can actually raise. */ readonly mayThrow: Set; readonly indirectMayThrow: boolean; /** Worker programs: the functions that poll for termination on entry * (workerTerminationPolls); null outside worker programs. */ - private readonly workerEntryPolls: ReadonlySet | null; + readonly workerEntryPolls: ReadonlySet | null; /** Functions that check the native stack guard on entry * (stackCheckedFunctions); null where the runtime has no guard. */ - private readonly stackChecks: ReadonlySet | null; + readonly stackChecks: ReadonlySet | null; /** Static types whose values can be published (@scriptc/threads): their * stores carry the frozen-object guard. Null without publish calls. */ readonly publishedTypes: PublishedTypes | null; @@ -938,8 +835,8 @@ export class LlEmitter { private readonly immutableFields: ImmutableFields | null; /** Constructor function names: their bodies may still write their own * `this`'s construction-only fields. */ - private readonly constructorFunctions: ReadonlySet; - private inConstructor = false; + readonly constructorFunctions: ReadonlySet; + inConstructor = false; /** Method names with at least one may-throw implementation — the * virtualCall pending check's key. */ readonly mayThrowMethods = new Set(); @@ -1015,30 +912,30 @@ export class LlEmitter { /** Class objects (classes as first-class values): className → the * interned .name literal ref — registered during body emission, the * statics and construct thunks assemble around the bodies. */ - private readonly classObjs = new Map(); + readonly classObjs = new Map(); private readonly unionFieldReadGroups = new Map(); /** Preorder intervals of the runtime error classes under THIS module's * class-forest numbering (main() stamps scr_error_vts with them, exactly * like the runtime ABI’s errorVtStampLines). */ - private readonly errorIntervals: { kind: number; pre: number; post: number; lib: string }[] = []; + readonly errorIntervals: { kind: number; pre: number; post: number; lib: string }[] = []; /** The runtime emitter vtable's preorder interval, when the program * touches node:events (the class def rides the module exactly then) — * main() stamps scr_emitter_vt with it (emitterVtStampLines, ported). */ - private emitterInterval: { pre: number; post: number } | null = null; + emitterInterval: { pre: number; post: number } | null = null; /** The runtime stream vtables' preorder intervals (streamVtStampLines, * ported): the defs ride every emitter-touching module (the frontend * collects the whole emitter-rooted tree), and scr_stream.c links on * the same predicate, so the stamps always have their globals. */ - private readonly streamIntervals: { vt: string; pre: number; post: number; lib: string }[] = []; + readonly streamIntervals: { vt: string; pre: number; post: number; lib: string }[] = []; // ── per-function state (reset in emitFunction) ───────────────────────── B = new BlockBuilder(); frames: LlValue[][] = []; - private scopes: LlScopeEntry[][] = []; + scopes: LlScopeEntry[][] = []; /** Scope slots dominate every throw site. Share their exceptional cleanup * for identical live bindings and handlers; statement temporaries stay at * their own throw sites because their SSA values are path-dependent. */ - private readonly unwindCleanups = new Map< + readonly unwindCleanups = new Map< string, { label: string; entries: LlScopeEntry[]; terminator: string } >(); @@ -1049,7 +946,7 @@ export class LlEmitter { * continue binds to the innermost LOOP; a labeled jump binds to the * entry whose `labels` contains its label. `contLabel` is null exactly * for blocks and switches. */ - private jumpTargets: { + jumpTargets: { kind: "loop" | "block" | "switch"; brkLabel: string; contLabel: string | null; @@ -1058,12 +955,12 @@ export class LlEmitter { scopeDepth: number; finallyDepth: number; }[] = []; - private currentLocals = new Map(); - private privateSplitLocals = new Map(); + currentLocals = new Map(); + privateSplitLocals = new Map(); readonly splitSpans = new Map(); - private storedSplits = new Map(); - private streamingSplitsEnabled = false; - private readonly initializerBindings: ReturnType; + storedSplits = new Map(); + streamingSplitsEnabled = false; + readonly initializerBindings: ReturnType; /** Whole-program int32 storage and parameter/result facts. */ readonly int32Slots: Int32Slots; /** The program has no runtime store into a checked-dynamic value @@ -1087,56 +984,56 @@ export class LlEmitter { /** ProgramLifetime hierarchy roots (contract types): their instances are * immortal from construction (classes.ts programLifetimeClass). */ readonly programLifetime: ReadonlySet; - private numericLocals = new Map(); + numericLocals = new Map(); /** Local arrays of the current function that no other binding can hold * (ir/array-length-stability.ts). */ - private unaliasedArrays: ReadonlySet = new Set(); + unaliasedArrays: ReadonlySet = new Set(); /** Dense array loops (dense-array-loops.ts) run in this function. */ - private denseLoopsEnabled = false; - private denseLoopEntries: ReturnType = new Map(); + denseLoopsEnabled = false; + denseLoopEntries: ReturnType = new Map(); /** The fast copy of a dense array loop being emitted. */ - private denseLoop: DenseArrayLoop | null = null; - private captureIds = new Set(); + denseLoop: DenseArrayLoop | null = null; + captureIds = new Set(); /** Active induction bindings retain their width independently of size_t. */ integerLoopBindings = new Map< string, { slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } } >(); - private countedLoopsEnabled = false; + countedLoopsEnabled = false; /** Speculative integer loops (speculative-integers.ts) run in this function. */ - private speculativeLoopsEnabled = false; + speculativeLoopsEnabled = false; /** The fast copy of a speculative integer loop being emitted: its * integer-computed writes and where each resumes in the ordinary copy. */ - private speculation: { + speculation: { writes: Set; resume: Map; /** Globals whose double is written back only when the copy is left. */ deferred: Set; } | null = null; /** The ordinary copy being emitted: resume labels still to place. */ - private speculationResume: Map | null = null; + speculationResume: Map | null = null; /** The function being emitted, for speculative globals. */ - private speculativeFunction = ""; + speculativeFunction = ""; /** Every function that writes each number global (computed on demand). */ private globalWriters: Map> | null = null; /** Whole-program final reference fields (ir/final-fields.ts). */ - private finalFields!: FinalFields; + finalFields!: FinalFields; /** The current function may borrow final fields of borrowable objects: * not a constructor (where they change), not async or a generator. */ - private finalFieldBorrows = false; + finalFieldBorrows = false; integerRanges: IntegerRanges = new Map(); /** Number arrays of the current function whose every present element is * an int32 (Int32[] contracts), or null when the module has none. */ int32Elements: ((arr: IrExpr) => boolean) | null = null; /** Sums whose result only feeds 32-bit conversions (wrapping-sums.ts). */ wrappingSums = new Set(); - private byteWindowsEnabled = true; - private byteWindowEntries: ReturnType = new Map(); + byteWindowsEnabled = true; + byteWindowEntries: ReturnType = new Map(); bytesBounds: ReadonlySet = new Set(); integerViews = new Map(); /** Module globals the current function owns outright (initializer * bindings): their function-local slot, which stands in for the global. */ - private privateGlobalSlots = new Map(); + privateGlobalSlots = new Map(); /** Number locals of the current function stored only as an i32 * (int32-locals.ts); their slots are `alloca i32`. */ int32Locals = new Map(); @@ -1151,7 +1048,7 @@ export class LlEmitter { * (past its own catch), so the copies emit under the truncated stack. * break/continue use the same region snapshots through * emitFinallysForJump. */ - private finallyStack: { + finallyStack: { frameDepth: number; scopeDepth: number; tryDepth: number; @@ -1162,15 +1059,15 @@ export class LlEmitter { * releases frames/scopes down to the recorded depths and branches to * `label` (the catch, or the exception-path finally) instead of * returning out of the function. Entering a try emits no code. */ - private tryStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number }[] = []; + tryStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number }[] = []; /** Return type of the function being emitted — the unwind path returns * a dummy of this type (never read: callers check the flag first). */ currentReturnType: IrType = VOID; /** The current body returns its number result as i32. */ - private currentInt32Result = false; + currentInt32Result = false; /** The function's i32 entry returns its scalar union result as tag and * payload bits (Int32Abi.union). */ - private currentUnionResult = false; + currentUnionResult = false; /** Active only while emitting a body lowered with LLVM's * switched-coroutine intrinsics (wasm32 async bodies and generators, * native synchronous generators; see coroLowered). */ @@ -1195,13 +1092,13 @@ export class LlEmitter { * catch prologue emits the GENRET sentinel re-unwind exactly here. */ currentGenerator: { yieldT: IrType; nextT: IrType } | null = null; /** The function being emitted is an async generator body. */ - private currentAsyncGenerator = false; + currentAsyncGenerator = false; /** Active optional-chain bind slots, by chain id (chainRecv reads). */ readonly chainSlots = new Map(); logArgSlots = 0; - private readonly stackTraces: boolean; + readonly stackTraces: boolean; /** Stack-trace mode: functions no capture can include (no frame push). */ - private readonly unframedFunctions: ReadonlySet; + readonly unframedFunctions: ReadonlySet; constructor( readonly mod: IrModule, @@ -1594,7 +1491,7 @@ export class LlEmitter { return this.virtualBorrows.entry(implFn); } - private implSlotBorrowed(implFn: string): ReadonlySet { + implSlotBorrowed(implFn: string): ReadonlySet { return this.virtualBorrows.implSlotBorrowed(implFn); } @@ -1631,398 +1528,22 @@ export class LlEmitter { throw new InternalCompilerError(`llvm emitter bug: no callback adapter for ${binding}:${id}`); return adapter; } - - /** C-callable scalar callback trampolines. A callback with an explicit - * context entry receives the closure at that exact ABI position. A raw - * callback loads it from a call-scoped TLS slot installed around the - * outer native call. */ - private emitFfiCallbackDefs(): { globals: string[]; defs: string[] } { - const globals: string[] = []; - const defs: string[] = []; - if (this.ffiCallbackAdapters.size === 0) return { globals, defs }; - this.declare(`declare void @scr_trap(ptr)`); - const expired = this.cstr( - "scriptc: native callback invoked outside its call-scoped lifetime\n", - ); - const released = this.cstr("scriptc: native callback invoked outside its retained lifetime\n"); - for (const adapter of this.ffiCallbackAdapters.values()) { - const cb = adapter.callback; - if (adapter.tls !== null) - globals.push(`@${adapter.tls} = internal thread_local global ptr null`); - if (adapter.global !== null) - globals.push(`@${adapter.global} = internal thread_local global ptr null`); - if (adapter.table !== null) { - globals.push( - `@${adapter.table} = internal ${this.mod.workers ? "thread_local " : ""}global %ScrFfiTable zeroinitializer`, - ); - } - const params = cb.params.flatMap((param, i): string[] => { - if (isFfiContextParam(param)) return [`ptr %ctx`]; - if (param === "string" || param === "bytes") { - return [`ptr %a${i}`, `${this.sizeType} %a${i}_len`]; - } - return [`${ffiNativeParamLl(param, this.ffiExtendNarrowIntegers)} %a${i}`]; - }); - if (cb.invoke === "foreign") { - if ( - adapter.table === null || - !cb.params.some(isFfiContextParam) || - cb.returns !== "void" || - cb.params.some((param) => param === "i64" || param === "u64" || param === "pointer") - ) { - throw new InternalCompilerError( - "llvm emitter bug: invalid foreign FFI callback descriptor", - ); - } - const dispatch = `${adapter.symbol}_dispatch`; - const scriptArgs: string[] = []; - const dispatchBody: string[] = [ - `define internal void @${dispatch}(ptr %cb, ptr %call) ${FN_ATTRS} {`, - `entry:`, - ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, - ` %fn = load ptr, ptr %fnp`, - ]; - for (let i = 0; i < cb.params.length; i++) { - const param = cb.params[i]!; - if (isFfiContextParam(param)) continue; - switch (param) { - case "f64": - case "f32": - case "i8": - case "u16": - case "i16": - this.declare(`declare double @scr_ffi_call_get_f64(ptr, ${this.sizeType})`); - dispatchBody.push( - ` %s${i} = call double @scr_ffi_call_get_f64(ptr %call, ${this.sizeType} ${i})`, - ); - scriptArgs.push(`double %s${i}`); - break; - case "bool": - this.declare(`declare zeroext i1 @scr_ffi_call_get_bool(ptr, ${this.sizeType})`); - dispatchBody.push( - ` %s${i} = call zeroext i1 @scr_ffi_call_get_bool(ptr %call, ${this.sizeType} ${i})`, - ); - scriptArgs.push(`i1 %s${i}`); - break; - case "u8": - case "u32": - case "i32": - this.declare(`declare double @scr_ffi_call_get_${param}(ptr, ${this.sizeType})`); - dispatchBody.push( - ` %s${i} = call double @scr_ffi_call_get_${param}(ptr %call, ${this.sizeType} ${i})`, - ); - scriptArgs.push(`double %s${i}`); - break; - case "cstring": - case "string": - case "bytes": { - this.declare(`declare ptr @scr_ffi_call_get_data(ptr, ${this.sizeType})`); - this.declare(`declare ${this.sizeType} @scr_ffi_call_get_len(ptr, ${this.sizeType})`); - dispatchBody.push( - ` %data${i} = call ptr @scr_ffi_call_get_data(ptr %call, ${this.sizeType} ${i})`, - ` %len${i} = call ${this.sizeType} @scr_ffi_call_get_len(ptr %call, ${this.sizeType} ${i})`, - ); - if (param === "bytes") { - this.declare(`declare ptr @scr_bytes_from_data(ptr, ${this.sizeType})`); - dispatchBody.push( - ` %s${i} = call ptr @scr_bytes_from_data(ptr %data${i}, ${this.sizeType} %len${i})`, - ); - } else { - this.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${this.sizeType})`); - dispatchBody.push( - ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %data${i}, ${this.sizeType} %len${i})`, - ); - } - scriptArgs.push(`ptr %s${i}`); - break; - } - } - } - dispatchBody.push( - ` call void %fn(${[`ptr %cb`, ...scriptArgs].join(", ")})`, - ` ret void`, - `}`, - ``, - ); - for (const line of dispatchBody) defs.push(line); - - this.declare(`declare ptr @scr_ffi_call_new(ptr, ptr, ptr, ${this.sizeType})`); - this.declare(`declare void @scr_ffi_post(ptr)`); - defs.push( - `define internal void @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, - `entry:`, - ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0`, - ` %call = call ptr @scr_ffi_call_new(ptr @${adapter.table}, ptr %cb, ptr @${dispatch}, ${this.sizeType} ${cb.params.length})`, - ); - for (let i = 0; i < cb.params.length; i++) { - const param = cb.params[i]!; - if (isFfiContextParam(param)) continue; - if (param === "cstring") { - this.declare(`declare void @scr_ffi_call_copy_cstring(ptr, ${this.sizeType}, ptr)`); - defs.push( - ` call void @scr_ffi_call_copy_cstring(ptr %call, ${this.sizeType} ${i}, ptr %a${i})`, - ); - } else if (param === "string" || param === "bytes") { - this.declare( - `declare void @scr_ffi_call_copy_${param}(ptr, ${this.sizeType}, ptr, ${this.sizeType})`, - ); - defs.push( - ` call void @scr_ffi_call_copy_${param}(ptr %call, ${this.sizeType} ${i}, ptr %a${i}, ${this.sizeType} %a${i}_len)`, - ); - } else if (param === "f32" || param === "i8" || param === "u16" || param === "i16") { - const nativeTy = ffiNativeTypeLl(param); - const op = param === "f32" ? "fpext" : param === "u16" ? "uitofp" : "sitofp"; - this.declare(`declare void @scr_ffi_call_set_f64(ptr, ${this.sizeType}, double)`); - defs.push( - ` %wide${i} = ${op} ${nativeTy} %a${i} to double`, - ` call void @scr_ffi_call_set_f64(ptr %call, ${this.sizeType} ${i}, double %wide${i})`, - ); - } else { - const nativeTy = ffiNativeTypeLl(param); - this.declare( - `declare void @scr_ffi_call_set_${param}(ptr, ${this.sizeType}, ${nativeTy})`, - ); - defs.push( - ` call void @scr_ffi_call_set_${param}(ptr %call, ${this.sizeType} ${i}, ${nativeTy} %a${i})`, - ); - } - } - defs.push(` call void @scr_ffi_post(ptr %call)`, ` ret void`, `}`, ``); - continue; - } - defs.push( - `define internal ${ffiNativeReturnLl(cb.returns, this.ffiExtendNarrowIntegers)} @${adapter.symbol}(${params.join(", ")}) ${FN_ATTRS} {`, - `entry:`, - adapter.tls !== null - ? ` %cb = load ptr, ptr @${adapter.tls}` - : adapter.global !== null - ? ` %cb = load ptr, ptr @${adapter.global}` - : ` %cb = getelementptr inbounds i8, ptr %ctx, i64 0`, - ` %missing = icmp eq ptr %cb, null`, - ` br i1 %missing, label %expired, label %ready`, - `expired:`, - ` call void @scr_trap(ptr ${adapter.callback.lifetime === "call" ? expired : released})`, - ` unreachable`, - `ready:`, - ...this.pendingTestLines("%pending"), - ` br i1 %pending, label %skip, label %invoke`, - `skip:`, - ` ret ${ffiCallbackDummyLl(cb)}`, - `invoke:`, - ); - // Validate every native pointer before allocating any copy-in value. - // A later bad slot therefore cannot leak an earlier materialization. - for (let i = 0; i < cb.params.length; i++) { - const param = cb.params[i]!; - if (param !== "cstring" && param !== "string" && param !== "bytes") continue; - const invalid = `%invalid${i}`; - defs.push(` %null${i} = icmp eq ptr %a${i}, null`); - if (param === "cstring") { - defs.push(` ${invalid} = or i1 %null${i}, false`); - } else { - defs.push( - ` %nonempty${i} = icmp ne ${this.sizeType} %a${i}_len, 0`, - ` ${invalid} = and i1 %null${i}, %nonempty${i}`, - ); - } - const message = - param === "cstring" - ? "scriptc: native callback passed a NULL cstring\n" - : `scriptc: native callback passed a NULL ${param} span with nonzero length\n`; - defs.push( - ` br i1 ${invalid}, label %invalid_param${i}, label %param_ok${i}`, - `invalid_param${i}:`, - ` call void @scr_trap(ptr ${this.cstr(message)})`, - ` unreachable`, - `param_ok${i}:`, - ); - } - defs.push( - ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, - ` %fn = load ptr, ptr %fnp`, - ); - const scriptArgs: string[] = []; - for (let i = 0; i < cb.params.length; i++) { - const param = cb.params[i]!; - if (isFfiContextParam(param)) continue; - switch (param) { - case "i64": - case "u64": - case "pointer": { - const ty = ffiNativeTypeLl(param); - this.declare(`declare ptr @scr_bigint_from_${param}(${ty})`); - defs.push(` %s${i} = call ptr @scr_bigint_from_${param}(${ty} %a${i})`); - scriptArgs.push(`ptr %s${i}`); - break; - } - case "f64": - scriptArgs.push(`double %a${i}`); - break; - case "f32": - case "i8": - case "u16": - case "i16": { - const nativeTy = ffiNativeTypeLl(param); - const op = param === "f32" ? "fpext" : param === "u16" ? "uitofp" : "sitofp"; - defs.push(` %s${i} = ${op} ${nativeTy} %a${i} to double`); - scriptArgs.push(`double %s${i}`); - break; - } - case "bool": - defs.push(` %s${i} = icmp ne i8 %a${i}, 0`); - scriptArgs.push(`i1 %s${i}`); - break; - case "u8": - defs.push(` %s${i} = uitofp i8 %a${i} to double`); - scriptArgs.push(`double %s${i}`); - break; - case "u32": - defs.push(` %s${i} = uitofp i32 %a${i} to double`); - scriptArgs.push(`double %s${i}`); - break; - case "i32": - defs.push(` %s${i} = sitofp i32 %a${i} to double`); - scriptArgs.push(`double %s${i}`); - break; - case "cstring": - this.declare(`declare ${this.sizeType} @strlen(ptr)`); - this.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${this.sizeType})`); - defs.push( - ` %len${i} = call ${this.sizeType} @strlen(ptr %a${i})`, - ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %a${i}, ${this.sizeType} %len${i})`, - ); - scriptArgs.push(`ptr %s${i}`); - break; - case "string": - this.declare(`declare ptr @scr_str_from_utf8_lossy(ptr, ${this.sizeType})`); - defs.push( - ` %s${i} = call ptr @scr_str_from_utf8_lossy(ptr %a${i}, ${this.sizeType} %a${i}_len)`, - ); - scriptArgs.push(`ptr %s${i}`); - break; - case "bytes": - this.declare(`declare ptr @scr_bytes_from_data(ptr, ${this.sizeType})`); - defs.push( - ` %s${i} = call ptr @scr_bytes_from_data(ptr %a${i}, ${this.sizeType} %a${i}_len)`, - ); - scriptArgs.push(`ptr %s${i}`); - break; - } - } - const ft = ffiCallbackType(cb); - const internalRet = this.llType(ft.ret); - const callArgs = [`ptr %cb`, ...scriptArgs].join(", "); - if (cb.lifetime === "retained") { - this.declare(`declare ptr @scr_closure_retain_v(ptr)`); - this.declare(`declare void @scr_closure_release_v(ptr)`); - defs.push(` %invoke_pin = call ptr @scr_closure_retain_v(ptr %cb)`); - } - if (cb.returns === "void") { - defs.push( - ` call void %fn(${callArgs})`, - ...(cb.lifetime === "retained" - ? [` call void @scr_closure_release_v(ptr %invoke_pin)`] - : []), - ` ret void`, - `}`, - ``, - ); - continue; - } - defs.push(` %result = call ${internalRet} %fn(${callArgs})`); - if (cb.lifetime === "retained") { - defs.push(` call void @scr_closure_release_v(ptr %invoke_pin)`); - } - switch (cb.returns) { - case "i64": - case "u64": - case "pointer": { - const ty = ffiNativeTypeLl(cb.returns); - this.declare(`declare ${ty} @scr_bigint_to_${cb.returns}(ptr)`); - this.declare(`declare void @scr_bigint_release(ptr)`); - defs.push( - ` %out = call ${ty} @scr_bigint_to_${cb.returns}(ptr %result)`, - ` call void @scr_bigint_release(ptr %result)`, - ` ret ${ty} %out`, - ); - break; - } - case "f64": - defs.push(` ret double %result`); - break; - case "f32": - defs.push(` %out = fptrunc double %result to float`, ` ret float %out`); - break; - case "bool": - defs.push(` %out = zext i1 %result to i8`, ` ret i8 %out`); - break; - case "u8": - case "i8": - case "u16": - case "i16": - case "u32": - this.declare(`declare double @scr_bit_ushr(double, double)`); - defs.push( - ` %coerced = call double @scr_bit_ushr(double %result, double ${f64Lit(0)})`, - ` %wide = fptoui double %coerced to i32`, - ); - if (cb.returns !== "u32") { - const nativeTy = ffiNativeTypeLl(cb.returns); - defs.push(` %out = trunc i32 %wide to ${nativeTy}`, ` ret ${nativeTy} %out`); - } else { - defs.push(` ret i32 %wide`); - } - break; - case "i32": - this.declare(`declare double @scr_bit_or(double, double)`); - defs.push( - ` %coerced = call double @scr_bit_or(double %result, double ${f64Lit(0)})`, - ` %out = fptosi double %coerced to i32`, - ` ret i32 %out`, - ); - break; - } - defs.push(`}`, ``); - } - return { globals, defs }; + emitFfiCallbackDefs(): { globals: string[]; defs: string[] } { + return emitFfiCallbackDefs(this); } emit(): string { return this.emitParts().join("\n"); } - emitParts(): string[] { - const parts = [ - ...limitInlineDynReleases(this.emitModuleParts()), - ...this.disposeObserverGates(), - ]; - if (!this.executableTls) return parts; - // A worker executable's thread-locals all live in the executable's own - // TLS block: the program's internal ones, and the runtime's external - // ones, because the runtime is always linked statically into the - // executable. So on ELF they can all use the local-exec model: LLVM's - // default general-dynamic sequence (relaxed by the linker to `mov %fs:0` - // plus an add), and even initial-exec (relaxed to `mov $offset` plus an - // indexed access), become one %fs-relative operand LLVM can fold into - // loads and compares. Darwin's thread-local variables have one model. - return parts.map((part) => - part - .replace( - /^(@[-$._A-Za-z0-9]+ = internal )thread_local global /gm, - "$1thread_local(localexec) global ", - ) - .replace( - /^(@[-$._A-Za-z0-9]+ = external )thread_local global /gm, - "$1thread_local(localexec) global ", - ), - ); + return emitParts(this); } /** The dispose-observer gates the emitted frees and record writers test * (alloc.ts): defined after every helper is emitted, because dynCheck * builders, JSON schemas and weak collection constructors are requested * lazily. */ - private disposeObserverGates(): string[] { + disposeObserverGates(): string[] { const weak = [...this.decls].some( (d) => d.includes("@scr_weak_map_new(") || d.includes("@scr_weak_set_new("), ); @@ -2032,1812 +1553,25 @@ export class LlEmitter { ``, ]; } - - private emitModuleParts(): string[] { - // Function bodies first (the literal/unit/fn-value tables fill as they - // emit), then the file assembles around them — the runtime ABI’s order. - const fnDefs: string[] = []; - for (const fn of this.mod.functions) { - const def = this.emitFunction(fn); - fnDefs.push(def); - this.fnDefText.set(fn.name, def); - } - this.selectStacklessAsync(fnDefs); - const errorViews: string[] = []; - for (const property of ["name", "message"]) - if (this.mod.functions.some((fn) => fn.name === `%error.${property}.read`)) { - this.declare(`declare void @scr_error_install_${property}_reader(ptr)`); - errorViews.push( - ` call void @scr_error_install_${property}_reader(ptr @${mangleFunction(`%error.${property}.read`)})`, - ); - } - for (const meta of this.classMeta.values()) { - if (meta.def.runtime || meta.root.def.name !== "%Error") continue; - const type: IrType = { kind: "object", className: meta.def.name }; - const adapter = this.liveDynRefAdapter(type); - const rc = vAdapters(this.shapeHost, type); - const key = typeKey(type); - const name = `sc_error_view_${errorViews.length}`; - this.resolveThunkDefs.push( - `define internal ptr @${name}(ptr %v) ${FN_ATTRS} {`, - `entry:`, - ` %d = call ptr ${typedRefConstructor(this.shapeHost, type)}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ptr ${this.cstr(key)}, ${this.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, - ` ret ptr %d`, - `}`, - ``, - ); - this.declare(`declare void @scr_error_register_dyn(ptr, ptr)`); - errorViews.push( - ` call void @scr_error_register_dyn(ptr @${mangleVtInstance(meta.def.name)}, ptr @${name})`, - ); - } - const layouts = emitLlvmLayouts( - this.shapeHost, - this.mod, - this.classMeta, - this.classObjs, - this.fnByName, - (t) => this.llType(t), - this.nullableFields, - this.int32Slots, - ); - const shapes = layouts.records; - const classShapes = layouts.classes; - const classObjDefs = layouts.classObjects; - const wrappers = this.emitFnValueDefs(); - const asyncDefs = this.emitAsyncScaffolding(); - const ffiCallbacks = this.emitFfiCallbackDefs(); - const hasNoInlineRecordClone = [...this.recordCloneShapes].some( - (shapeId) => (this.recordsById.get(shapeId)?.fields.length ?? 0) >= 16, - ); - - // Module globals, FIRST OCCURRENCE per id: a class-expression static - // instantiated through several mixin applications registers one global - // id several times (2041-mixin-values) — C's tentative definitions - // accept the duplicates without an error, LLVM rejects redefinition, so the - // storage AND the release emit once per id here. - const seenGlobalIds = new Set(); - const globals = (this.mod.globals ?? []).filter((g) => { - if (seenGlobalIds.has(g.id)) return false; - seenGlobalIds.add(g.id); - return true; - }); - // Refcounted globals release before main returns — the runtime ABI’s - // sc_release_globals, keeping the RC audit's live count exact. Built - // before the declaration table flushes (it adds the release symbols). - // Two spellings with distinct temp names: the normal exit and the - // uncaught-exception exit are separate blocks of the same function. - // Interned function-value closures are IMMORTAL (rc == SIZE_MAX), so - // an own-property table Object.defineProperties hung on one would - // outlive the RC audit — release it with the globals (the runtime ABI’s - // sc_release_globals tail). Only when the dispatch unit is even - // linked (defineProps is the only writer). - const runtimeFeatures = moduleRuntimeFeatures(this.mod); - const fnValueProps = [...this.fnValues]; - if (fnValueProps.length > 0) this.declare(`declare void @scr_box_release(ptr)`); - if (this.classObjs.size > 0) this.declare(`declare void @scr_classobj_clear_properties(ptr)`); - const globalReleaseLines = (prefix: string): string[] => { - const lines: string[] = []; - globals.forEach((g, i) => { - if (!isRefCounted(g.type)) return; - lines.push( - ` %${prefix}${i} = load ptr, ptr @${mangleGlobal(g.id)}`, - ` call void ${releaseSym(this.shapeHost, g.type)}(ptr %${prefix}${i}) ; ${g.name}`, - ); - }); - fnValueProps.forEach((name, i) => { - // props sits at %ScrClosure field 3; release is NULL-tolerant — - // cleared so a second release path stays idempotent. - lines.push( - ` %${prefix}fp${i} = load ptr, ptr getelementptr inbounds (%ScrClosure, ptr @${mangleFnClosure(name)}, i64 0, i32 3)`, - ` call void @scr_box_release(ptr %${prefix}fp${i}) ; ${name}.props`, - ` store ptr null, ptr getelementptr inbounds (%ScrClosure, ptr @${mangleFnClosure(name)}, i64 0, i32 3)`, - ); - }); - for (const name of this.classObjs.keys()) - lines.push(` call void @scr_classobj_clear_properties(ptr @${mangleClassObj(name)})`); - return lines; - }; - // Exit listeners can read MODULE GLOBALS directly, so they must run - // BEFORE the global releases (the runtime ABI’s runExitListeners - // ordering — the atexit half becomes an idempotent no-op). - const usesEvents = runtimeFeatures.processEvents; - const usesChildProcess = runtimeFeatures.childProcess; - const usesFsWatch = runtimeFeatures.fsWatch; - // Stream-surface programs fill the loop's stream hook (the deferred - // next-tick emissions) and the emitter's post-registration flow kick - // before %main — scr_stream.c links only when the line is emitted - // (native/executable.ts gates on the same predicate). - const usesStream = runtimeFeatures.stream; - // Net-surface programs fill the loop's net hooks (and the netSocket - // handle-dispatch ops for the checked-dynamic boundary); http-surface - // programs additionally stamp the httpReq/httpRes ops — the C main's - // install lines, gated on the same predicates native/executable.ts links by. - const usesNet = runtimeFeatures.net; - const usesDgram = runtimeFeatures.dgram; - const usesHttp2 = runtimeFeatures.http2; - const usesHttp = runtimeFeatures.http; - // Fetch-referencing programs register native fetch before main. - // Runtime selection compiles scr_fetch.c on the same predicate. - const usesFetch = runtimeFeatures.fetch; - const snapshotsTlsCa = runtimeFeatures.tls || runtimeFeatures.tlsCa; - // The process verdict has the same precedence as the C reference - // emitter: node:test owns the final status when present; otherwise an - // embedded process.exitCode owns it; ordinary programs return zero. - const usesNodeTest = runtimeFeatures.nodeTest; - // Declared NOW — the extern block flushes before main assembles. - if (usesEvents) this.declare(`declare void @scr_events_install()`); - if (usesChildProcess) this.declare(`declare void @scr_child_dyn_install()`); - const childStreamBoxes = - usesChildProcess && - usesStream && - this.classMeta.has("%Readable") && - this.classMeta.has("%Writable") - ? [ - this.dyn.toDynHelper({ kind: "object", className: "%Readable" }), - this.dyn.toDynHelper({ kind: "object", className: "%Writable" }), - ] - : null; - // worker.stdout and worker.stderr box the stream unit's Readable the - // same way, and worker.stdin its Writable when the program has one - // (scr_worker_stdio_install, scr_worker_events.c). - const workerStdioBox = - this.mod.workers === true && usesStream && this.classMeta.has("%Readable") - ? (childStreamBoxes?.[0] ?? - this.dyn.toDynHelper({ kind: "object", className: "%Readable" })) - : null; - const workerStdinBox = - this.mod.workers === true && usesStream && this.classMeta.has("%Writable") - ? (childStreamBoxes?.[1] ?? - this.dyn.toDynHelper({ kind: "object", className: "%Writable" })) - : null; - if (childStreamBoxes || workerStdioBox || workerStdinBox) { - this.declare( - `@scr_stream_child_ops = external constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr }`, - ); - } - if (childStreamBoxes) - this.declare(`declare void @scr_child_dyn_streams_install(ptr, ptr, ptr)`); - if (workerStdioBox || workerStdinBox) - this.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`); - if (usesFsWatch) this.declare(`declare void @scr_watch_install()`); - if (this.ffiHasForeignCallback) this.declare(`declare void @scr_ffi_install()`); - if (usesStream) this.declare(`declare void @scr_stream_install()`); - if (usesNet) { - this.declare(`declare void @scr_net_install()`); - this.declare(`declare void @scr_net_dyn_install()`); - } - if (usesDgram) this.declare(`declare void @scr_dgram_install()`); - if (usesHttp2) this.declare(`declare void @scr_http2_dyn_install()`); - if (usesHttp) this.declare(`declare void @scr_http_dyn_install()`); - if (usesFetch) this.declare(`declare void @scr_fetch_install()`); - - if (usesNodeTest) { - this.declare(`declare i32 @scr_test_exit_code()`); - this.declare(`declare void @scr_test_init()`); - } - if (snapshotsTlsCa) { - this.declare(`declare void @scr_tls_ca_install()`); - } - // Inline exit listeners run when something they must beat exists: - // the refcounted-global releases, or the retained-FFI atexit ledger - // sweep (a listener may legitimately release or pump a registration, - // and only the inline call orders ahead of every atexit handler — - // the runtime ABI’s runExitListeners stance). Plain event programs - // with neither keep the atexit path, so their listener timing is - // unchanged. - const hasRefGlobals = globals.some((g) => isRefCounted(g.type)) || fnValueProps.length > 0; - const inlineExitListeners = - usesEvents && (this.mod.workers || hasRefGlobals || this.ffiHasRetainedCallback); - this.declare(`declare i32 @scr_exit_code_hint_get()`); - if (inlineExitListeners) { - this.declare(`declare void @scr_run_exit_listeners(double)`); - this.declare(`declare i32 @scr_exit_code_hint_get()`); - } - const exitListenerLines = (prefix: string): string[] => { - if (!inlineExitListeners) return []; - return [ - ` %${prefix}h = call i32 @scr_exit_code_hint_get()`, - ` %${prefix}hd = sitofp i32 %${prefix}h to double`, - ` call void @scr_run_exit_listeners(double %${prefix}hd)`, - ]; - }; - // The entry's exits release the module globals only where the thread - // frees its heap when its script ends (scr_exit_frees: workers, and - // builds that check for leaks); the main thread of other builds leaves - // it to the operating system. - const exitReleaseLines = (prefix: string): string[] => { - const releases = globalReleaseLines(prefix); - if (releases.length === 0) return releases; - this.declare(`declare zeroext i1 @scr_exit_frees()`); - return [ - ` %${prefix}.frees = call zeroext i1 @scr_exit_frees()`, - ` br i1 %${prefix}.frees, label %${prefix}.release, label %${prefix}.kept`, - `${prefix}.release:`, - ...releases, - ` br label %${prefix}.kept`, - `${prefix}.kept:`, - ]; - }; - const globalReleases = exitReleaseLines("g"); - const asyncEntry = this.fnByName.get(this.mod.entry)?.async === true; - const entryMayThrow = this.mayThrow.has(this.mod.entry); - // The event loop runs when timers appeared OR any async/generator - // function exists (the C main's hasAsync || hasGenerators || - // usesTimers gate). Generator programs run - // the loop too: its exit accounting notes still-suspended generator - // fibers as abandoned, so the RC audit downgrades exactly like the - // async loop-exhaustion story. - const runsLoop = - this.usesTimers || - this.ffiHasForeignCallback || - this.mod.functions.some((f) => f.async === true || f.generator !== undefined); - const uncaughtReleases = entryMayThrow && !asyncEntry ? exitReleaseLines("gu") : []; - const loopReleasesU = runsLoop ? exitReleaseLines("gl") : []; - const loopReleasesR = runsLoop ? exitReleaseLines("gr") : []; - const topRejectReleases = asyncEntry ? exitReleaseLines("gt") : []; - const topPendingReleases = asyncEntry ? exitReleaseLines("gp") : []; - const loopReportedReleases = runsLoop ? exitReleaseLines("gq") : []; - // main's epilogues read the flag / the loop entry points — declared - // HERE, before the extern block flushes. They run once, so they keep - // the out-of-line scr_exc_pending call; per-call checks inline the - // test (pendingTestLines). - if (entryMayThrow || runsLoop) this.declare(`declare zeroext i1 @scr_exc_pending()`); - if (runsLoop) { - this.declare(`declare zeroext i1 @scr_loop_run(ptr)`); - if (this.mod.commonJsEntry === true) this.declare(`declare void @scr_loop_commonjs_entry()`); - this.declare(`declare zeroext i1 @scr_report_unhandled_rejections()`); - this.declare(`declare void @scr_discard_unhandled_rejections()`); - } - if (asyncEntry) { - this.declare(`declare i32 @scr_promise_finish_top_level(ptr)`); - this.declare(`declare void @scr_promise_rethrow_top_level(ptr)`); - this.declare(`declare void @scr_promise_release(ptr)`); - this.declare(`declare void @scr_exit_code_note(i32)`); - } - const topPendingExitLines = (): string[] => { - if (!asyncEntry) return []; - const lines: string[] = []; - // finish_top_level noted 13 unless the program set process.exitCode. - // A failed node:test run outranks either before exit listeners run. - if (usesNodeTest) { - lines.push( - ` %tla_program_exit = call i32 @scr_test_exit_code()`, - ` %tla_program_exit_zero = icmp eq i32 %tla_program_exit, 0`, - ` %tla_hint = call i32 @scr_exit_code_hint_get()`, - ` %tla_exit_status = select i1 %tla_program_exit_zero, i32 %tla_hint, i32 %tla_program_exit`, - ` call void @scr_exit_code_note(i32 %tla_exit_status)`, - ); - } - for (const line of exitListenerLines("xp")) lines.push(line); - for (const line of topPendingReleases) lines.push(line); - // An exit listener may set process.exitCode again, as in Node. - lines.push( - ` %tla_exit_code = call i32 @scr_exit_code_hint_get()`, - ` ret i32 %tla_exit_code`, - ); - return lines; - }; - // LIBRARY mode: the runtime entry points the generated library - // symbols delegate to — declared before the extern block flushes. - if (this.mod.lib !== undefined) { - if (this.wasi) { - this.declare(`declare ptr @malloc(${this.sizeType})`); - this.declare(`declare void @free(ptr)`); - } - this.declare(`declare void @scr_library_entry(i1 zeroext, ptr)`); - this.declare(`declare void @scr_library_reset()`); - this.declare(`declare void @scr_library_check_exc()`); - this.declare(`declare void @scr_library_set_sink(ptr, ptr)`); - this.declare(`declare void @scr_library_callback_entry_guard(ptr)`); - this.declare(`declare void @scr_library_arena_reset()`); - this.declare(`declare void @scr_library_collect()`); - if ((this.mod.lib.callbacks?.length ?? 0) > 0) { - // Host-callback channels: the registration define's dispatch - // (strcmp over the declared names + the runtime slot store). - // The call-site fetch pair (scr_library_cb_require/_ctx) is - // declared at ffiCall emission like every body-driven runtime - // symbol. - this.declare(`declare void @scr_library_cb_set(${this.sizeType}, ptr, ptr)`); - this.declare(`declare i32 @strcmp(ptr, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.params.includes("string"))) { - this.declare(`declare ptr @scr_library_str_in(ptr, ${this.sizeType})`); - } - if (this.mod.lib.exports.some((e) => e.params.includes("bytes"))) { - this.declare(`declare ptr @scr_library_bytes_in(ptr, ${this.sizeType}, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.params.includes("i64"))) { - this.declare(`declare double @scr_library_i64_in(i64, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.params.includes("u64"))) { - this.declare(`declare double @scr_library_u64_in(i64, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.returns === "string")) { - this.declare(`declare void @scr_library_str_out(ptr, ptr, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.returns === "bytes")) { - this.declare(`declare void @scr_library_bytes_out(ptr, ptr, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.params.includes("cstring"))) { - this.declare(`declare ptr @scr_library_cstr_in(ptr, ptr)`); - } - if (this.mod.lib.exports.some((e) => e.returns === "cstring")) { - this.declare(`declare ptr @scr_library_cstr_out(ptr)`); - } - } - // Helpers assemble BEFORE the declaration table flushes (they add - // write/abort declarations). - const helpers = [ - ...this.helperDefs(), - ...emitArraySearchHelpers(this.arraySearchKinds, this.sizeType, (line) => this.declare(line)), - ...emitClassMembershipHelper(this.classMeta, this.sizeType), - ...(this.publishWalkersCache?.defs() ?? []), - ...this.publishedClassNamesDefs(), - ...(this.regions?.moduleDefs() ?? []), - ]; - if (this.constantNumericTables.size > 0) - this.declare(`declare double @scr_arr_get_number(ptr, double)`); - // Native matchers of the regex literals inside the compiled subset. - const regexMatchers = new Map(); - for (const [key, re] of this.regexInstances) { - const code = compileRegexMatcher( - `${re.sym}_match`, - re.pattern, - re.flags, - this.sizeType, - !this.wasi, - ); - if (code === null) continue; - regexMatchers.set(key, code); - for (const decl of code.decls) this.declare(decl); - } - - const out: string[] = [ - `; Generated by scriptc (LLVM backend) from ${this.mod.sourceFile}. Do not edit.`, - ``, - // Type shapes shared with the runtime's C ABI. ScrStr is the header - // prefix only (the flexible-array tail is concrete per literal); - // ScrLogArg is { i32 tag; 8-byte union } — i64 at offset 8 matches - // the C layout (4 bytes padding after the tag). ScrUnion/ScrClosure - // mirror scr_runtime.h field-for-field (tag reads, slot peeks, the - // fn pointer, and the caps[] tail all address through them). - `%ScrStr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType} }`, - `%ScrLogArg = type { i32, i64 }`, - `%ScrVt = type { ${this.sizeType}, ${this.sizeType}, ptr }`, - `%ScrUnion = type { ${this.sizeType}, i32, ptr, ptr, ptr, i64 }`, - `%ScrClosure = type { ${this.sizeType}, ptr, ${this.sizeType}, ptr, i32, i32, ptr, ptr }`, - `%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`, - `%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr, double, ptr, ptr, ptr }`, - // ScrArr mirrors scr_runtime.h field-for-field: { rc, len, cap, elem, - // cyc, spare, filled, ops, data, present, ext } (field numbers in - // SCR_ARR_FIELD), where filled bounds the hole-free prefix, ops points at the element type's constant RC descriptor - // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse, - // property and metadata storage (NULL until first used). Live dynamic - // stream commits swap its dense, presence and out-of-line storage while - // preserving the target object's identity. - `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i8, i8, i16, i32, ptr, ptr, ptr, ptr }`, - `%ScrArrOps = type { ptr, ptr, ptr }`, - // The ScrMap prefix { rc, key_kind, val_kind, traced } — the inline - // RC fast paths test the traced byte (shapes.ts). - ...(this.rcHelpers === null ? [] : [`%ScrMapRc = type { ${this.sizeType}, i8, i8, i8 }`]), - // The complete ScrMap and its { key, val, hash } entry, read by the - // inline number-key lookup (sc_map_entry_f64) and the inline - // iteration steps (sc_map_iter_entry): field 8 is the i32 entry - // count, field 10 the i64 absent-key filter, entries is field 11, - // nbuckets and buckets fields 13 and 14, the direct index and its i32 - // length fields 15 and 16. - ...(this.decls.has(NUMBER_MAP_ENTRY_DECL) || - this.decls.has(STRING_MAP_ENTRY_DECL) || - this.decls.has(MAP_ITER_DECL) - ? [ - `%ScrMapIx = type { ${this.sizeType}, i8, i8, i8, i8, i32, ptr, ptr, i32, i32, i64, ptr, i32, i32, ptr, ptr, i32, i32 }`, - ] - : []), - // String-keyed reference reads load the value slot of the entry - // scr_map_entry_str returns (map-number-lookup.ts). - ...(this.decls.has(NUMBER_MAP_ENTRY_DECL) || - this.decls.has(STRING_MAP_ENTRY_DECL) || - this.decls.has(MAP_ITER_DECL) - ? [`%ScrMapEntry = type { i64, i64, i64 }`] - : []), - // The runtime error prefix { rc, vt, name, message, code, cause } and the - // class-object shape { rc, pre, post, ctor, name } — field reads on - // builtin errors and classval loads GEP through these. - `%ScrError = type { ${this.sizeType}, ptr, ptr, ptr, ptr, ptr, i8, i8, i8, i8, i8, ptr, ptr }`, - `%ScrClassObj = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`, - // The runtime emitter prefix { rc, vt, reg, cls } — user subclasses - // embed it (classes.ts), and bare-emitter GEPs address through it. - `%ScrEmitter = type { ${this.sizeType}, ptr, ptr, ptr }`, - // The runtime stream layout { rc, vt, reg, cls, st } — one struct - // for all five stream classes; stream subclasses embed it. - `%ScrStream = type { ${this.sizeType}, ptr, ptr, ptr, ptr }`, - // The EventTarget and Event subclass prefixes { rc, vt, state, cls }. - ...(this.classMeta.has(RUNTIME_EVENT_TARGET_CLASS) - ? [`%ScrEventTargetObj = type { ${this.sizeType}, ptr, ptr, ptr }`] - : []), - ...(this.classMeta.has(RUNTIME_EVENT_CLASS) - ? [`%ScrEventObj = type { ${this.sizeType}, ptr, ptr, ptr }`] - : []), - // The catch-binding snapshot box { rc, kind, f64, b, payload, - // retain_fn, release_fn, trace_fn } — caughtTest kind reads and - // caughtNarrow payload extraction GEP through it (offsets match - // scr_runtime.h's natural alignment: kind at 8, f64 at 16, b at 24, - // payload at 32). - `%ScrCaught = type { ${this.sizeType}, i32, double, i8, ptr, ptr, ptr, ptr }`, - // ScrBytes { rc, len, elem(i32+pad), data, backing, brands, shared }. Indexed - // typed-array access GEPs through this directly: the IR type already - // fixes elem, so the hot path needs neither a runtime kind load nor - // the generic scr_bytes_get/set call. - `%ScrBytes = type { ${this.sizeType}, ${this.sizeType}, i32, ptr, ptr, i8, i8, i8, i8, ptr }`, - // The capture box { rc, kind, obj_retain, obj_release, obj_trace, - // slot } — TDZ reads peek the payload slot (offset 40) directly. - `%ScrBox = type { ${this.sizeType}, i32, ptr, ptr, ptr, i64 }`, - // The stack buffer of the emitted JSON serializers { data, len, cap }. - `%ScrJsonBuf = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, ${this.sizeType}, ${this.sizeType} }`, - // The dynCheck error-path spine { parent, key, index } — the emitted - // builders stack-allocate one per recursion level (dyn.ts). - `%ScrDynPath = type { ptr, ptr, ${this.sizeType} }`, - `%ScrIslandModule = type { ptr, ptr, ${this.sizeType}, ${this.sizeType}, i32, ptr, ${this.sizeType}, ${this.sizeType} }`, - `%ScrIslandEdge = type { ptr, ptr, ptr, i32 }`, - // A generator's prefix { rc, receiver, fiber, state, out } and its - // value slot { kind, f64, b, payload, release_fn } (scr_async.c pins - // both): number and boolean yields and drives address OUT directly. - ...(this.usesGenSlots - ? [ - `%ScrGenSlot = type { i32, double, i8, ptr, ptr }`, - `%ScrGenHead = type { ${this.sizeType}, ptr, ptr, i32, %ScrGenSlot }`, - ] - : []), - ]; - for (const line of shapes.typeDefs) out.push(line); - for (const line of classShapes.typeDefs) out.push(line); - // Thread-instanced library state (abi.instance_per_thread): the - // program TU's mutable globals — module globals, run-once guards, the - // lazily-compiled regex literal caches — and the runtime globals its - // init stamps live in thread-local storage, matching the runtime - // objects compiled with -DSCR_THREAD_INSTANCES. Immutable interned - // data (string literals, unit arms, template arrays, vtables) stays - // shared. - const tl = - this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""; - out.push( - ``, - `@scr_error_vts = external ${tl}global [${RUNTIME_ERROR_CLASSES.size} x %ScrVt]`, - `declare void @scr_init()`, - `declare void @scr_lib_init(i32, ptr)`, - ...(this.mod.workers ? [`declare void @scr_runtime_workers_v8()`] : []), - ...(this.stackChecks?.size ? [`declare void @scr_stack_guard_init()`] : []), - ...(this.runtimeAbiMarker && this.mod.lib === undefined - ? [`declare void @${RUNTIME_ABI_MARKER}()`] - : []), - // Overrides the runtime's weak default (scr_lib.c). - ...(this.detachedExit && this.mod.lib === undefined - ? [`@scr_detached_exit = constant i8 1`] - : []), - ); - for (const d of this.decls) out.push(d); - out.push(``); - const flushedDecls = new Set(this.decls); - { - const tl = - this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""; - out.push(...threadSlotsBlockDefinitions(this.threadSlotsBlocks, tl !== "")); - for (let site = 0; site < this.dynReadIcSites; site++) - out.push( - `@sc_dyn_read_ic_${site} = internal ${tl}global { ptr, ${this.sizeType}, ${this.sizeType}, ptr } zeroinitializer`, - ); - } - for (const table of this.constantNumericTables.values()) { - const n = table.values.length; - let tableTmp = 0; - const tableIndex = exactIndexLines("%i", String(n), "i64", () => `%ix${tableTmp++}`); - out.push( - `@${table.symbol} = private constant [${n} x double] [${table.values.map((v) => `double ${f64Lit(v)}`).join(", ")}]`, - `define internal double @${table.symbol}_get(ptr %a, double %i) alwaysinline #0 {`, - `entry:`, - ` %initialized = icmp ne ptr %a, null`, - ...tableIndex.lines.map((line) => ` ${line}`), - ` %safe = and i1 %initialized, ${tableIndex.ok}`, - ` br i1 %safe, label %read, label %fallback`, - `read:`, - ` %slot = getelementptr inbounds [${n} x double], ptr @${table.symbol}, i64 0, i64 ${tableIndex.wide}`, - ` %value = load double, ptr %slot`, - ` ret double %value`, - `fallback:`, - ` %generic = call double @scr_arr_get_number(ptr %a, double %i)`, - ` ret double %generic`, - `}`, - ``, - ); - } - for (const [text, lit] of this.literals) { - // Immortal interned ScrStr: { rc = SIZE_MAX, len, cap = len, bytes\0 } — - // the runtime ABI’s static table, retain/release skip rc == SIZE_MAX. - // 64-bit (little-endian) targets split the capacity word: the high - // half carries the precomputed Map key hash (SCR_STR_HASH_CACHE). - // Bit 31 marks all-ASCII literals (SCR_STR_ASCII_BIT) on every target. - const capWord = - this.sizeType === "i64" - ? literalCapWord(wtf8Bytes(text)) - : literalCapWord32(wtf8Bytes(text)); - out.push( - `@${lit.sym} = internal global { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, [${lit.len + 1} x i8] } ` + - `{ ${this.sizeType} -1, ${this.sizeType} ${lit.len}, ${this.sizeType} ${capWord}, [${lit.len + 1} x i8] c"${llStrBytes(text)}" }`, - ); - } - if (this.literals.size > 0) out.push(``); - for (const lit of this.bigintLiterals.values()) { - // Immortal ScrBigInt: { rc = SIZE_MAX, len, sign, cap, limbs } with - // pointer-sized little-endian limbs (scr_bigint.c); zero keeps one - // zero limb so limb 0 is always readable. - const size = this.sizeType; - const limbBits = size === "i64" ? 64n : 32n; - const mask = (1n << limbBits) - 1n; - const limbs: string[] = []; - for (let m = lit.value < 0n ? -lit.value : lit.value; m > 0n; m = m >> limbBits) { - limbs.push(`${size} ${m & mask}`); - } - const len = limbs.length; - if (len === 0) limbs.push(`${size} 0`); - const sign = lit.value < 0n ? -1 : lit.value > 0n ? 1 : 0; - out.push( - `@${lit.sym} = internal global { ${size}, ${size}, i32, i32, [${limbs.length} x ${size}] } ` + - `{ ${size} -1, ${size} ${len}, i32 ${sign}, i32 ${limbs.length}, [${limbs.length} x ${size}] [${limbs.join(", ")}] }`, - ); - } - if (this.bigintLiterals.size > 0) out.push(``); - for (const [key, sym] of this.unitInstances) { - // One immortal instance per unit-armed (union, tag): tag set, payload - // slot and RC entry points zero — retain/release/collector all skip - // rc == SIZE_MAX, so these never join the RC audit or a trace walk. - const [unionId, tag] = key.split(":"); - out.push( - `@${sym} = internal global %ScrUnion { ${this.sizeType} -1, i32 ${tag}, ptr null, ptr null, ptr null, i64 0 } ; ${unionId} unit arm`, - ); - } - if (this.unitInstances.size > 0) out.push(``); - for (const [unionId, sym] of this.retypeGuards) { - // Thread-local like module globals: a thread's cast caches hold only - // its own boxes. Never written, the flag folds to false. - out.push(`@${sym} = internal ${tl}global i8 0 ; ${unionId} boxes in a cast cache`); - } - if (this.retypeGuards.size > 0) out.push(``); - for (const [unionId, sym] of this.absentInstances) { - // One immortal ABSENT field-slot instance per undefined-armed union: - // the undefined arm's tag with payload 1 (ordinary unit instances - // carry 0). Every tag reader sees undefined; recordHas and the - // field reads compare tag and payload, never the address, so the - // per-object copies of separately compiled units agree. - const tag = undefinedArmTag({ kind: "union", unionId }, this.unionsById); - out.push( - `@${sym} = internal global %ScrUnion { ${this.sizeType} -1, i32 ${tag}, ptr null, ptr null, ptr null, i64 ${ABSENT_FIELD_PAYLOAD} } ; ${unionId} absent field`, - ); - } - if (this.absentInstances.size > 0) out.push(``); - if (this.needsUnionNone) - out.push( - `@sc_union_none = internal constant %ScrUnion zeroinitializer ; NULL union dummy read by inline stores`, - ``, - ); - if (this.nullableUnions.usesNullSentinel) { - // `null` in every `C | null | undefined` union (NULL is undefined): - // one module-scope immortal object like the ABSENT sentinel. - const S = this.sizeType; - out.push( - `${NULLABLE_NULL} = internal global { ${S}, ${S}, ${S}, ${S} } { ${S} -1, ${S} 0, ${S} 0, ${S} 0 } ; nullable null arm`, - ``, - ); - } - if (this.needsNullableAbsent) { - // The ABSENT field-slot state of every nullable-pointer union: one - // module-scope immortal object (rc == SIZE_MAX), distinct from NULL - // (undefined) and from every instance. RC entry points and the - // collector skip it like any immortal; field reads compare its address. - const S = this.sizeType; - out.push( - `${NULLABLE_ABSENT} = internal global { ${S}, ${S}, ${S}, ${S} } { ${S} -1, ${S} 0, ${S} 0, ${S} 0 } ; nullable absent field`, - ``, - ); - } - for (const [key, re] of this.regexInstances) { - // One immortal ScrRegex per (pattern, flags) literal, pointing at - // the interned source/flags strings. The bc and translated-program - // slots start null (lazy compile, cached by the runtime) — a mutable - // global, not constant. The last slot is the build-time matcher. - const matcher = regexMatchers.has(key) ? `@${re.sym}_match` : "null"; - out.push( - `@${re.sym} = internal ${tl}global %ScrRegex { ${this.sizeType} -1, ptr ${re.src}, ptr ${re.fl}, ptr null, double 0.0, ptr null, ptr null, ptr ${matcher} } ; ${key.replace(/\n/g, "\\n")}`, - ); - } - if (this.regexInstances.size > 0) out.push(``); - for (const code of regexMatchers.values()) out.push(...code.lines, ``); - for (const [, inst] of this.templateStringsInstances) { - // One immortal ScrArr per tagged-template site: a [N x ptr] data - // global of interned cooked-string literals, and the ScrArr header - // over it (rc == SIZE_MAX, len == cap == filled, SCR_ELEM_STR = 2, - // no REF entry points). Every dense slot is present; reads retain - // immortal strings — a no-op. - const n = inst.slots.length; - const present = - n === 0 ? "zeroinitializer" : `[ ${inst.slots.map(() => "i8 1").join(", ")} ]`; - out.push( - `@${inst.sym}_data = internal constant [${n} x ptr] [ ${inst.slots.map((s) => `ptr ${s}`).join(", ")} ]`, - `@${inst.sym}_present = internal constant [${n} x i8] ${present}`, - `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`, - ); - } - if (this.templateStringsInstances.size > 0) out.push(``); - for (const [text, c] of this.cstrs) { - // NUL-terminated byte-array constants for scr_jb_puts and indentation. - out.push(`@${c.sym} = internal constant [${c.len + 1} x i8] c"${llStrBytes(text)}"`); - } - if (this.cstrs.size > 0) out.push(``); - for (const [body, sym] of this.mapOpsConsts) { - out.push(`@${sym} = internal constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr } { ${body} }`); - } - if (this.mapOpsConsts.size > 0) out.push(``); - - for (const line of ffiCallbacks.globals) out.push(line); - if (ffiCallbacks.globals.length > 0) out.push(``); - for (const g of globals) { - const ty = this.llType(g.type); - const zero = ty === "double" ? f64Lit(0) : ty === "ptr" ? "null" : "false"; - const debug = this.debug?.global(g); - out.push( - `@${mangleGlobal(g.id)} = internal ${tl}global ${ty} ${zero}${debug ? `, !dbg ${debug}` : ""} ; ${g.name}`, - ); - } - if (globals.length > 0) out.push(``); - for (const line of helpers) out.push(line); - for (const line of ffiCallbacks.defs) out.push(line); - for (const line of shapes.defs) out.push(line); - for (const line of classShapes.defs) out.push(line); - for (const line of classObjDefs) out.push(line); - for (const line of this.walkers.defs) out.push(line); - for (const line of this.dyn.defs) out.push(line); - for (const line of wrappers) out.push(line); - for (const line of asyncDefs) out.push(line); - for (const line of this.resolveThunkDefs) out.push(line); - out.push(fnDefs[0] ?? ""); - for (let i = 1; i < fnDefs.length; i++) out.push("", fnDefs[i]!); - out.push(""); - - // main(): scr_init, the program-dependent error-vt interval stamps, - // scr_lib_init(argc, argv), then the entry function. An uncaught - // exception escaping top-level code prints and exits 1 (Node). - // The exception epilogue is emitted when the entry may throw. - const stamps: string[] = [...errorViews]; - for (const iv of this.errorIntervals) { - const fields: [number, number][] = [ - [0, iv.pre], - [1, iv.post], - ]; - for (const [field, value] of fields) { - stamps.push( - ` store ${this.sizeType} ${value}, ptr getelementptr inbounds ([${RUNTIME_ERROR_CLASSES.size} x %ScrVt], ptr @scr_error_vts, i64 0, i64 ${iv.kind}, i32 ${field})${field === 1 ? ` ; ${iv.lib}` : ""}`, - ); - } - } - if (this.emitterInterval !== null) { - // The runtime emitter vtable's interval (emitterVtStampLines): bare - // EventEmitter instances answer instanceof and dispatch dynamic - // teardown under THIS module's preorder numbering. - out.push(`@scr_emitter_vt = external ${tl}global %ScrVt`, ``); - stamps.push( - ` store ${this.sizeType} ${this.emitterInterval.pre}, ptr getelementptr inbounds (%ScrVt, ptr @scr_emitter_vt, i64 0, i32 0)`, - ` store ${this.sizeType} ${this.emitterInterval.post}, ptr getelementptr inbounds (%ScrVt, ptr @scr_emitter_vt, i64 0, i32 1) ; EventEmitter`, - ); - } - for (const iv of this.streamIntervals) { - // The runtime stream vtables' intervals (streamVtStampLines) — the - // emitter story: instanceof and dynamic teardown dispatch through - // them. - out.push(`@${iv.vt} = external ${tl}global %ScrVt`); - stamps.push( - ` store ${this.sizeType} ${iv.pre}, ptr getelementptr inbounds (%ScrVt, ptr @${iv.vt}, i64 0, i32 0)`, - ` store ${this.sizeType} ${iv.post}, ptr getelementptr inbounds (%ScrVt, ptr @${iv.vt}, i64 0, i32 1) ; ${iv.lib}`, - ); - } - if (this.streamIntervals.length > 0) out.push(``); - if ([...this.literals.keys(), ...this.cstrs.keys()].some(hasLoneSurrogate)) { - // The runtime skips surrogate work until a lone surrogate may exist - // (scr_wtf8_any); its own producers note one, and literals do here. - out.push(`declare void @scr_wtf8_note()`, ``); - stamps.push(` call void @scr_wtf8_note()`); - } - if (this.errorIntervals.length > 0 && this.tracedShapes.has("object:%Error")) { - // The cycle fixpoint marked the Error hierarchy (a user subclass - // holds cycle-capable fields — capability is hierarchy-uniform), so - // the runtime's own error allocations need collector headers too. - // Declared inline: the extern block already flushed (LLVM is - // order-free, the helper-defs precedent). - out.push(`declare void @scr_error_set_traced()`, ``); - stamps.push(` call void @scr_error_set_traced()`); - } - if ((entryMayThrow || runsLoop) && this.mod.lib === undefined) { - // Declared inline: the extern block already flushed (LLVM is - // order-free — the scr_error_set_traced precedent). Only the - // printer emits here (nothing else declares it); scr_exc_pending - // and the loop entry points rode the Set before the flush. - out.push( - `declare void @scr_exc_print_uncaught()`, - `declare zeroext i1 @scr_exc_handle_uncaught(i1 zeroext)`, - ``, - ); - } - if (this.mod.lib !== undefined) { - // LIBRARY mode: no @main — the profile-declared external - // symbols specified by the library IR instead. - for (const line of this.emitLibDefs(globals, globalReleaseLines, stamps)) out.push(line); - out.push(...this.inlineRcTail(flushedDecls)); - out.push(`attributes #0 = { sanitize_address }`); - if (this.usesCoroutines) out.push(`attributes #1 = { sanitize_address presplitcoroutine }`); - if (hasNoInlineRecordClone) out.push(`attributes #2 = { noinline sanitize_address }`); - out.push(``); - out.push(...this.fieldAliasMetadata()); - if (this.debug !== null) out.push(this.debug.render()); - return out; - } - out.push( - `define i32 @${this.mod.workers ? "sc_context_entry" : this.wasi ? "__main_argc_argv" : "main"}(i32 %argc, ptr %argv) ${FN_ATTRS} {`, - `entry:`, - ...(this.runtimeAbiMarker ? [` call void @${RUNTIME_ABI_MARKER}()`] : []), - ...(this.mod.workers ? [` call void @scr_runtime_workers_v8()`] : []), - ` call void @scr_init()`, - // Each context (the main thread, every worker) installs its stack - // guard before running compiled code that checks it. - ...(this.stackChecks?.size ? [` call void @scr_stack_guard_init()`] : []), - ...stamps, - // Event-surface programs (signal/exit listeners) fill the loop's - // nullable event hooks before %main — scr_events.c links only when - // this line is emitted (native/executable.ts gates on the same predicate). - ...(usesEvents ? [` call void @scr_events_install()`] : []), - ...(usesChildProcess ? [` call void @scr_child_dyn_install()`] : []), - ...(childStreamBoxes - ? [ - ` call void @scr_child_dyn_streams_install(ptr @scr_stream_child_ops, ptr @${childStreamBoxes[0]}, ptr @${childStreamBoxes[1]})`, - ] - : []), - ...(workerStdioBox || workerStdinBox - ? [ - ` call void @scr_worker_stdio_install(ptr @scr_stream_child_ops, ptr ${workerStdioBox ? `@${workerStdioBox}` : "null"}, ptr ${workerStdinBox ? `@${workerStdinBox}` : "null"})`, - ] - : []), - // fs.watch programs fill the loop's watch hooks the same way — - // scr_watch.c links only when this line is emitted. - ...(usesFsWatch ? [` call void @scr_watch_install()`] : []), - ...(this.ffiHasForeignCallback ? [` call void @scr_ffi_install()`] : []), - ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), - ...(usesFetch ? [` call void @scr_fetch_install()`] : []), - ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), - ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), - ...(usesDgram ? [` call void @scr_dgram_install()`] : []), - ...(usesHttp2 ? [` call void @scr_http2_dyn_install()`] : []), - ...(usesStream ? [` call void @scr_stream_install()`] : []), - ` call void @scr_lib_init(i32 %argc, ptr %argv)`, - // node:test takes Node's --test-update-snapshots out of argv first. - ...(usesNodeTest ? [` call void @scr_test_init()`] : []), - ...(asyncEntry - ? [` %top = call ptr @${mangleAsyncSpawn(this.mod.entry)}()`] - : [` call void @${mangleFunction(this.mod.entry)}()`]), - // Uncaught exception from top-level code: Node exits 1. - ...(entryMayThrow && !asyncEntry - ? [ - ` %exc = call zeroext i1 @scr_exc_pending()`, - ` br i1 %exc, label %uncaught, label %ok`, - `uncaught:`, - ` %handled = call zeroext i1 @scr_exc_handle_uncaught(i1 false)`, - ` br i1 %handled, label %ok, label %fatal`, - `fatal:`, - ` call void @scr_exc_print_uncaught()`, - ...exitListenerLines("xu"), - ...uncaughtReleases, - ` %failure_code_0 = call i32 @scr_exit_code_hint_get()`, - ` ret i32 %failure_code_0`, - `ok:`, - ] - : []), - // The event loop runs to exhaustion (microtasks before timers). A - // throw escaping a timer callback and unhandled promise rejections - // both exit 1, like Node — the C main's loop block exactly. - ...(runsLoop - ? [ - ...(this.mod.commonJsEntry === true ? [` call void @scr_loop_commonjs_entry()`] : []), - ` %loop_rejection = call zeroext i1 @scr_loop_run(ptr ${asyncEntry ? "%top" : "null"})`, - ` %lexc = call zeroext i1 @scr_exc_pending()`, - ` br i1 %lexc, label %luncaught, label %lok`, - `luncaught:`, - ` call void @scr_exc_print_uncaught()`, - ...(asyncEntry ? [` call void @scr_promise_release(ptr %top)`] : []), - ...exitListenerLines("xl"), - ...loopReleasesU, - ` %failure_code_1 = call i32 @scr_exit_code_hint_get()`, - ` ret i32 %failure_code_1`, - `lok:`, - ` br i1 %loop_rejection, label %lreported, label %lclean`, - `lreported:`, - ` call void @scr_discard_unhandled_rejections()`, - ...(asyncEntry ? [` call void @scr_promise_release(ptr %top)`] : []), - ...exitListenerLines("xq"), - ...loopReportedReleases, - ` %failure_code_2 = call i32 @scr_exit_code_hint_get()`, - ` ret i32 %failure_code_2`, - `lclean:`, - ...(asyncEntry - ? [ - ` %tla_status = call i32 @scr_promise_finish_top_level(ptr %top)`, - ` %tla_rejected = icmp eq i32 %tla_status, 1`, - ` br i1 %tla_rejected, label %tla_fail, label %tla_not_rejected`, - `tla_fail:`, - // The loop already delivered every earlier-checkpoint - // rejection. Drop same-checkpoint competitors before - // surfacing the fatal module verdict. - ` call void @scr_discard_unhandled_rejections()`, - ` call void @scr_promise_rethrow_top_level(ptr %top)`, - ` call void @scr_promise_release(ptr %top)`, - ` call void @scr_exc_print_uncaught()`, - ...exitListenerLines("xt"), - ...topRejectReleases, - ` %failure_code_3 = call i32 @scr_exit_code_hint_get()`, - ` ret i32 %failure_code_3`, - `tla_not_rejected:`, - ` call void @scr_promise_release(ptr %top)`, - ] - : []), - ` %rej = call zeroext i1 @scr_report_unhandled_rejections()`, - ` br i1 %rej, label %lrej, label %lrok`, - `lrej:`, - ...exitListenerLines("xr"), - ...loopReleasesR, - ` %failure_code_4 = call i32 @scr_exit_code_hint_get()`, - ` ret i32 %failure_code_4`, - `lrok:`, - ...(asyncEntry - ? [ - ` %tla_pending = icmp eq i32 %tla_status, 13`, - ` br i1 %tla_pending, label %tla_stuck, label %tla_ok`, - `tla_stuck:`, - ...topPendingExitLines(), - `tla_ok:`, - ] - : []), - ] - : []), - ...exitListenerLines("xn"), - ...globalReleases, - ...(usesNodeTest - ? [` %test_exit = call i32 @scr_test_exit_code()`, ` ret i32 %test_exit`] - : [` %native_exit = call i32 @scr_exit_code_hint_get()`, ` ret i32 %native_exit`]), - `}`, - ``, - ...this.inlineRcTail(flushedDecls), - // sanitize_address is inert under the plain pipeline; the sanitized - // lane's -fsanitize=address link activates instrumentation over the - // emitted functions too (the runtime TUs get theirs from clang). - `attributes #0 = { sanitize_address }`, - ...(this.usesCoroutines ? [`attributes #1 = { sanitize_address presplitcoroutine }`] : []), - ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address }`] : []), - ``, - ); - if (this.mod.workers) { - out.push( - `declare void @scr_context_cleanup()`, - `declare i32 @scr_worker_argc()`, - `declare ptr @scr_worker_argv()`, - `define i32 @sc_worker_entry(i32 %root) ${FN_ATTRS} {`, - `entry:`, - ` %argc = call i32 @scr_worker_argc()`, - ` %argv = call ptr @scr_worker_argv()`, - ` %result = call i32 @sc_context_entry(i32 %argc, ptr %argv)`, - ` ret i32 %result`, - `}`, - `define i32 @main(i32 %argc, ptr %argv) ${FN_ATTRS} {`, - `entry:`, - ` %result = call i32 @sc_context_entry(i32 %argc, ptr %argv)`, - ` call void @scr_context_cleanup()`, - ` ret i32 %result`, - `}`, - ); - } - out.push(...this.fieldAliasMetadata()); - if (this.debug !== null) out.push(this.debug.render()); - return out; + emitModuleParts(): string[] { + return emitModuleParts(this); } - - /** LIBRARY mode: the profile-declared external definitions — the - * export-map wrappers plus init / sink-registration / reset / collect. - * Plain `define` (not `define internal`) — the exact linkage distinction - * that separates the executable lane's @main from everything else. The - * bodies delegate every runtime half to scr_library.c, mirroring the C - * emission line for line, so the two lanes are identical by - * construction. */ - private emitLibDefs( + emitLibDefs( globals: IrGlobal[], globalReleaseLines: (prefix: string) => string[], stamps: string[], ): string[] { - const lib = this.mod.lib!; - const autoReset = lib.resultResetSymbol === null; - const out: string[] = [``, `; ── library-mode entries (profile: ${lib.profileName}) ──`, ``]; - // Every entry's prologue records its external symbol in the funnel's - // current-entry slot (structured trap-teaching field 2); the symbols - // live as internal constants, one per entry. - const symConst = (sym: string): string => `@sc_lib_sym_${sym}`; - const emitSymConst = (sym: string): void => { - out.push( - `${symConst(sym)} = internal constant [${Buffer.byteLength(sym, "utf8") + 1} x i8] c"${llStrBytes(sym)}"`, - ); - }; - emitSymConst(lib.initSymbol); - emitSymConst(lib.sinkRegisterSymbol); - if (lib.callbackRegisterSymbol !== null && lib.callbackRegisterSymbol !== undefined) - emitSymConst(lib.callbackRegisterSymbol); - if (lib.resultResetSymbol !== null) emitSymConst(lib.resultResetSymbol); - if (lib.collectSymbol !== null) emitSymConst(lib.collectSymbol); - for (const e of lib.exports) emitSymConst(e.symbol); - out.push(``); - if (this.wasi) { - for (const symbol of ["scriptc_alloc", "scriptc_free"]) emitSymConst(symbol); - out.push( - `define ptr @scriptc_alloc(i32 %size) ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst("scriptc_alloc")})`, - ` %p = call ptr @malloc(i32 %size)`, - ` ret ptr %p`, - `}`, - ``, - `define void @scriptc_free(ptr %p) ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst("scriptc_free")})`, - ` call void @free(ptr %p)`, - ` ret void`, - `}`, - ``, - ); - } - // The runtime detected-trap overlay table (scr_runtime.h declares it, - // the library trap funnel consults it): flat code/teaching/remediation - // triples, one per runtime trap code (SC4013–SC4019) the profile - // declares text for. The funnel assembles the structured sink message. - // The empty table still defines the symbols the funnel links against. - const ovlCells: string[] = []; - lib.trapOverlays.forEach((o, i) => { - const cell = (name: string, text: string | undefined): void => { - if (text === undefined) { - ovlCells.push("ptr null"); - return; - } - const sym = `@sc_lib_ovl_${i}_${name}`; - out.push( - `${sym} = internal constant [${Buffer.byteLength(text, "utf8") + 1} x i8] c"${llStrBytes(text)}"`, - ); - ovlCells.push(`ptr ${sym}`); - }; - cell("code", o.code); - cell("teach", o.teaching); - cell("rem", o.remediation); - }); - out.push( - ovlCells.length === 0 - ? `@scr_library_trap_overlays = constant [1 x ptr] zeroinitializer` - : `@scr_library_trap_overlays = constant [${ovlCells.length} x ptr] [${ovlCells.join(", ")}]`, - `@scr_library_trap_overlays_len = constant ${this.sizeType} ${lib.trapOverlays.length}`, - ``, - ); - // The init entry: full deterministic reset-and-reevaluate. Program - // globals release and zero first (run-once guards included), then the - // runtime session reset, the error-vt interval stamps verbatim from - // the executable main, %main itself, and the escaped-exception check. - const zeroStores = globals.map((g) => { - const ty = this.llType(g.type); - const zero = ty === "double" ? f64Lit(0) : ty === "ptr" ? "null" : "false"; - return ` store ${ty} ${zero}, ptr @${mangleGlobal(g.id)} ; ${g.name}`; - }); - out.push( - `define void @${lib.initSymbol}() ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_entry(i1 zeroext true, ptr ${symConst(lib.initSymbol)}) ; init always resets the result arena`, - ...globalReleaseLines("ci"), - ...zeroStores, - ` call void @scr_library_reset()`, - ...stamps, - ` call void @${mangleFunction(this.mod.entry)}()`, - ` call void @scr_library_check_exc()`, - ` ret void`, - `}`, - ``, - `define void @${lib.sinkRegisterSymbol}(ptr %fn, ptr %ctx) ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_callback_entry_guard(ptr ${symConst(lib.sinkRegisterSymbol)})`, - ` call void @scr_library_set_sink(ptr %fn, ptr %ctx)`, - ` ret void`, - `}`, - ``, - ); - if (lib.callbacks !== undefined && lib.callbacks.length > 0) { - // Host-callback channels: the per-channel name constants (the - // registration dispatch's strcmp operands), the per-channel - // unregistered-call trap constants (the ffiCall sites' - // scr_library_cb_require operands), and the registration function: - // a pure store dispatch - // (the sink registration's rule — no entry prologue, no poison - // guard) whose first operation rejects callback-time re-entry - // (SC4026). An unknown or NULL name is a defined -1, never a store. - for (const cb of lib.callbacks) { - out.push( - `@sc_lib_cb_name_${cb.slot} = internal constant [${Buffer.byteLength(cb.name, "utf8") + 1} x i8] c"${llStrBytes(cb.name)}"`, - `@sc_lib_cb_trap_${cb.slot} = internal constant [${Buffer.byteLength(cb.unregisteredTrap, "utf8") + 1} x i8] c"${llStrBytes(cb.unregisteredTrap)}"`, - ); - } - out.push( - ``, - `define i32 @${lib.callbackRegisterSymbol}(ptr %name, ptr %fn, ptr %ctx) ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_callback_entry_guard(ptr ${symConst(lib.callbackRegisterSymbol!)})`, - ` %isnull = icmp eq ptr %name, null`, - ` br i1 %isnull, label %miss, label %try0`, - ); - lib.callbacks.forEach((cb, i) => { - const next = i + 1 < lib.callbacks!.length ? `try${i + 1}` : "miss"; - out.push( - `try${i}: ; channel '${cb.name}'`, - ` %cmp${i} = call i32 @strcmp(ptr %name, ptr @sc_lib_cb_name_${cb.slot})`, - ` %eq${i} = icmp eq i32 %cmp${i}, 0`, - ` br i1 %eq${i}, label %set${i}, label %${next}`, - `set${i}:`, - ` call void @scr_library_cb_set(${this.sizeType} ${cb.slot}, ptr %fn, ptr %ctx)`, - ` ret i32 0`, - ); - }); - out.push(`miss:`, ` ret i32 -1`, `}`, ``); - } - if (lib.identity !== undefined && this.emitLibraryIdentity) { - // Profile-declared identity getters (the ask-2 sidecar's boot-time - // pairing fence): pure data returns with NO entry prologue — exempt - // from the poisoned guard and every runtime touch (ratified), so a - // host can read them before init and after a trap. The u64 rides - // i64 two's-complement (LLVM integer constants are signed). - for (const line of emitLibraryIdentityLines(lib.identity, FN_ATTRS)) out.push(line); - } - if (lib.resultResetSymbol !== null) { - out.push( - `define void @${lib.resultResetSymbol}() ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst(lib.resultResetSymbol)})`, - ` call void @scr_library_arena_reset()`, - ` ret void`, - `}`, - ``, - ); - } - if (lib.collectSymbol !== null) { - out.push( - `define void @${lib.collectSymbol}() ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_library_entry(i1 zeroext false, ptr ${symConst(lib.collectSymbol)})`, - ` call void @scr_library_collect() ; arena reset + a full cycle collection`, - ` ret void`, - `}`, - ``, - ); - } - for (const e of lib.exports) { - const params: string[] = []; - const body: string[] = [ - ` call void @scr_library_entry(i1 zeroext ${autoReset ? "true" : "false"}, ptr ${symConst(e.symbol)})`, - ]; - const args: string[] = []; - if (e.inboundBytesTrap !== undefined) { - // The bytes-in helper's trap message: the compiler-assembled - // structured trap-teaching form (0x01 text 0x1F SC4012 0x1F symbol - // [0x1F remediation]) consumed by the runtime sink. - const trapBytes = Buffer.byteLength(e.inboundBytesTrap, "utf8"); - out.push( - `@sc_lib_bytes_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundBytesTrap)}"`, - ``, - ); - } - if (e.inboundIntTrap !== undefined) { - // The i64/u64-in helpers' host-contract trap message (ask 4): an - // inbound integer past ±(2^53−1) cannot ride f64 exactly. Same - // assembled structured form, same SC4012 code, same - // emission-invariance argument as the bytes trap. - const trapBytes = Buffer.byteLength(e.inboundIntTrap, "utf8"); - out.push( - `@sc_lib_int_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundIntTrap)}"`, - ``, - ); - } - if (e.inboundCstringTrap !== undefined) { - // The cstring-in helper's host-contract trap message (a NULL - // `const char *`): same assembled structured form and SC4012 code. - const trapBytes = Buffer.byteLength(e.inboundCstringTrap, "utf8"); - out.push( - `@sc_lib_cstr_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundCstringTrap)}"`, - ``, - ); - } - e.params.forEach((cls, i) => { - switch (cls) { - case "f64": - params.push(`double %a${i}`); - args.push(`double %a${i}`); - break; - case "bool": - params.push(`i8 %a${i}`); - body.push(` %c${i} = icmp ne i8 %a${i}, 0`); - args.push(`i1 %c${i}`); - break; - case "u8": - params.push(`i8 %a${i}`); - body.push(` %c${i} = uitofp i8 %a${i} to double`); - args.push(`double %c${i}`); - break; - case "u32": - params.push(`i32 %a${i}`); - body.push(` %c${i} = uitofp i32 %a${i} to double`); - args.push(`double %c${i}`); - break; - case "i32": - params.push(`i32 %a${i}`); - body.push(` %c${i} = sitofp i32 %a${i} to double`); - args.push(`double %c${i}`); - break; - case "i64": - // Inbound declared-integer edge (ask 4): the helper converts - // exactly or delivers the host-contract trap (past ±(2^53−1) - // the value cannot ride f64 without rounding). - params.push(`i64 %a${i}`); - body.push( - ` %c${i} = call double @scr_library_i64_in(i64 %a${i}, ptr @sc_lib_int_trap_${e.symbol})`, - ); - args.push(`double %c${i}`); - break; - case "u64": - params.push(`i64 %a${i}`); - body.push( - ` %c${i} = call double @scr_library_u64_in(i64 %a${i}, ptr @sc_lib_int_trap_${e.symbol})`, - ); - args.push(`double %c${i}`); - break; - case "string": - params.push(`ptr %a${i}_ptr`, `${this.sizeType} %a${i}_len`); - body.push( - ` %c${i} = call ptr @scr_library_str_in(ptr %a${i}_ptr, ${this.sizeType} %a${i}_len)`, - ); - args.push(`ptr %c${i}`); - break; - case "bytes": - params.push(`ptr %a${i}_ptr`, `${this.sizeType} %a${i}_len`); - body.push( - ` %c${i} = call ptr @scr_library_bytes_in(ptr %a${i}_ptr, ${this.sizeType} %a${i}_len, ptr @sc_lib_bytes_trap_${e.symbol})`, - ); - args.push(`ptr %c${i}`); - break; - case "cstring": - params.push(`ptr %a${i}`); - body.push( - ` %c${i} = call ptr @scr_library_cstr_in(ptr %a${i}, ptr @sc_lib_cstr_trap_${e.symbol})`, - ); - args.push(`ptr %c${i}`); - break; - } - }); - if (e.returns === "string" || e.returns === "bytes") { - params.push(`ptr %out`, `ptr %out_len`); - } - const target = `@${mangleFunction(e.fnName)}`; - const callArgs = args.join(", "); - let retType = "void"; - switch (e.returns) { - case "void": - body.push( - ` call void ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` ret void`, - ); - break; - case "f64": - retType = "double"; - body.push( - ` %r = call double ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` ret double %r`, - ); - break; - case "i64": - case "u64": - // The outbound declared-integer edge (ask 4): every value - // reaching this return was PROVEN whole and inside the class's - // range at compile time, so the fp-to-int conversion is exact - // by construction — the crossing carries the mathematically - // exact integer the f64 held. - retType = "i64"; - body.push( - ` %r = call double ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` %z = ${e.returns === "i64" ? "fptosi" : "fptoui"} double %r to i64`, - ` ret i64 %z`, - ); - break; - case "bool": - retType = "i8"; - body.push( - ` %r = call i1 ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` %z = zext i1 %r to i8`, - ` ret i8 %z`, - ); - break; - case "string": - body.push( - ` %r = call ptr ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` call void @scr_library_str_out(ptr %r, ptr %out, ptr %out_len)`, - ` ret void`, - ); - break; - case "bytes": - body.push( - ` %r = call ptr ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` call void @scr_library_bytes_out(ptr %r, ptr %out, ptr %out_len)`, - ` ret void`, - ); - break; - case "cstring": - // The result's arena copy, returned as `const char *`. - retType = "ptr"; - body.push( - ` %r = call ptr ${target}(${callArgs})`, - ` call void @scr_library_check_exc()`, - ` %z = call ptr @scr_library_cstr_out(ptr %r)`, - ` ret ptr %z`, - ); - break; - } - out.push( - `define ${retType} @${e.symbol}(${params.join(", ")}) ${FN_ATTRS} { ; library export ${e.fnName}`, - `entry:`, - ...body, - `}`, - ``, - ); - } - return out; + return emitLibDefs(this, globals, globalReleaseLines, stamps); } - - /** The shared abort helpers (emitted only when referenced): the OOM - * abort of untraced shape allocation and the invalid-union-tag abort — - * both print the runtime ABI’s exact message on fd 2 and abort. */ - private helperDefs(): string[] { - const defs: string[] = []; - const msgHelper = (fnName: string, msgSym: string, msg: string): void => { - // The message routes through the runtime's trap funnel: executable - // builds expand to the historical bytes-on-stderr + abort; library - // builds route to the registered panic sink (scr_runtime.h). - const bytes = Buffer.byteLength(msg, "utf8"); - this.declare(`declare void @scr_trap(ptr)`); - defs.push( - `@${msgSym} = internal constant [${bytes + 1} x i8] c"${llStrBytes(msg)}"`, - `define internal void @${fnName}() ${FN_ATTRS} {`, - `entry:`, - ` call void @scr_trap(ptr @${msgSym})`, - ` unreachable`, - `}`, - ``, - ); - }; - if (this.needsOom) msgHelper("sc_oom", "sc_oom_msg", "scriptc: out of memory\n"); - if (this.needsBadTag) { - msgHelper("sc_bad_tag", "sc_bad_tag_msg", "scriptc: internal error: invalid union tag\n"); - } - if (this.needsBadKey) { - // The keyed-read miss on a result type that cannot say `undefined`: - // trap like an array OOB read instead of corrupting a typed slot - // (SEMANTICS.md; the C helper's message additionally interpolates - // the runtime key — a trap-path debugging nicety, never reachable - // by a program whose behavior matches Node). - msgHelper( - "sc_bad_key", - "sc_bad_key_msg", - "scriptc: TypeError: record has no key (typed slot — no undefined is representable)\n", - ); - } - if (this.needsRetainBox) { - // scr_box_retain is a static inline (increment-unless-immortal); - // emitted once with internal linkage, like the record retains. - defs.push( - `define internal ptr @sc_retain_box(ptr %b) ${FN_ATTRS} {`, - `entry:`, - ` %rc = load ${this.sizeType}, ptr %b`, - ` %imm = icmp eq ${this.sizeType} %rc, -1`, - ` br i1 %imm, label %done, label %inc`, - `inc:`, - ` %n = add ${this.sizeType} %rc, 1`, - ` store ${this.sizeType} %n, ptr %b`, - ` br label %done`, - `done:`, - ` ret ptr %b`, - `}`, - ``, - ); - } - // Number-key Map/Set lookups probe the runtime's direct index inline: - // while ndense is nonzero every live key is an integer below it, so an - // in-range integral key reads its slot (entry index + 1, 0 = absent) and - // any other key is absent. Maps without the index (ndense 0) hash the - // key in sc_map_entry_f64_hashed, which scans the entries of a small map - // or probes a compact bucket table as scr_map_find_f64 does, and calls - // the runtime lookup for a wide table. It stays out of line so that the - // direct-index path stays small at every call site. Both only read - // memory, so LLVM merges lookups of one key when nothing is stored in - // between (a has() followed by get(), for example). - if (this.decls.has(NUMBER_MAP_ENTRY_DECL)) { - const sz = this.sizeType; - let mapTmp = 0; - const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`); - defs.push( - `define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`, - `entry:`, - ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`, - ...(sz === "i64" - ? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`] - : [` %nd = load i32, ptr %ndp`]), - // A non-integral key inside the table is as absent as one outside it. - ...mapIndex.lines.map((line) => ` ${line}`), - ` br i1 ${mapIndex.ok}, label %slot, label %outside`, - `slot:`, - sz === "i64" - ? ` %i = add i64 ${mapIndex.wide}, 0` - : ` %i = trunc i64 ${mapIndex.wide} to ${sz}`, - ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`, - ` %dense = load ptr, ptr %dp`, - ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, - ` %s = load i32, ptr %sp`, - ` %empty = icmp eq i32 %s, 0`, - ` br i1 %empty, label %miss, label %hit`, - `hit:`, - ` %e32 = sub i32 %s, 1`, - sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, - ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, - ` %entries = load ptr, ptr %ep`, - ` %found = getelementptr inbounds %ScrMapEntry, ptr %entries, ${sz} %e`, - ` ret ptr %found`, - `miss:`, - ` ret ptr null`, - `outside:`, - ` %dense.any = icmp ne ${sz} %nd, 0`, - ` br i1 %dense.any, label %miss, label %slow`, - `slow:`, - ` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`, - ` ret ptr %hashed`, - `}`, - ``, - // Hashing (scr_map_find_f64): the SameValueZero key word, its - // scr_key_mix hash (zero remapped to one) and the absent-key filter. - `define internal ptr @sc_map_entry_f64_hashed(ptr %m, double %k) noinline ${FN_ATTRS} {`, - `entry:`, - ` %nan = fcmp uno double %k, %k`, - ` %zero = fcmp oeq double %k, 0.0`, - ` %kz = select i1 %zero, double 0.0, double %k`, - ` %kzb = bitcast double %kz to i64`, - ` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`, - ` %x1s = lshr i64 %kb, 30`, - ` %x1 = xor i64 %kb, %x1s`, - ` %x2 = mul i64 %x1, -4658895280553007687`, - ` %x3s = lshr i64 %x2, 27`, - ` %x3 = xor i64 %x2, %x3s`, - ` %x4 = mul i64 %x3, -7723592293110705685`, - ` %x5s = lshr i64 %x4, 31`, - ` %x5 = xor i64 %x4, %x5s`, - ` %hz = icmp eq i64 %x5, 0`, - ` %h = select i1 %hz, i64 1, i64 %x5`, - ` %f1s = lshr i64 %h, 44`, - ` %f1a = and i64 %f1s, 63`, - ` %f1 = shl i64 1, %f1a`, - ` %f2s = lshr i64 %h, 50`, - ` %f2a = and i64 %f2s, 63`, - ` %f2 = shl i64 1, %f2a`, - ` %fb = or i64 %f1, %f2`, - ` %fp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10`, - ` %filter = load i64, ptr %fp`, - ` %fm = and i64 %filter, %fb`, - ` %fin = icmp eq i64 %fm, %fb`, - ` br i1 %fin, label %table, label %miss`, - `table:`, - ` %nbp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 13`, - ...(sz === "i64" - ? [` %nb32 = load i32, ptr %nbp`, ` %nb = zext i32 %nb32 to i64`] - : [` %nb = load i32, ptr %nbp`]), - ` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, - ` %ents = load ptr, ptr %eps`, - ` %linear = icmp eq ${sz} %nb, 0`, - ` br i1 %linear, label %scan, label %buckets`, - // Small maps keep no bucket table: scan the cached entry hashes. - `scan:`, - ` %nep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, - ...(sz === "i64" - ? [` %ne32 = load i32, ptr %nep`, ` %ne = zext i32 %ne32 to i64`] - : [` %ne = load i32, ptr %nep`]), - ` br label %scan.loop`, - `scan.loop:`, - ` %si = phi ${sz} [ 0, %scan ], [ %si.next, %scan.next ]`, - ` %sdone = icmp eq ${sz} %si, %ne`, - ` br i1 %sdone, label %miss, label %scan.entry`, - `scan.entry:`, - ` %se = getelementptr inbounds %ScrMapEntry, ptr %ents, ${sz} %si`, - ` %seh = getelementptr inbounds %ScrMapEntry, ptr %se, i32 0, i32 2`, - ` %sh = load i64, ptr %seh`, - ` %sk = load i64, ptr %se`, - ` %shq = icmp eq i64 %sh, %h`, - ` %skq = icmp eq i64 %sk, %kb`, - ` %sq = and i1 %shq, %skq`, - ` br i1 %sq, label %scan.found, label %scan.next`, - `scan.found:`, - ` ret ptr %se`, - `scan.next:`, - ` %si.next = add ${sz} %si, 1`, - ` br label %scan.loop`, - // Compact tables tag each 24-bit entry index (+1) with the hash's - // top byte; wider tables take the runtime lookup. - `buckets:`, - ` %wide = icmp ugt ${sz} %nb, 16777216`, - ` br i1 %wide, label %runtime, label %probe`, - `probe:`, - ` %bp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 14`, - ` %bk = load ptr, ptr %bp`, - ` %mask = sub ${sz} %nb, 1`, - ` %ht = lshr i64 %h, 32`, - ` %ht32 = trunc i64 %ht to i32`, - ` %tag = and i32 %ht32, -16777216`, - sz === "i64" ? ` %hi = add i64 %h, 0` : ` %hi = trunc i64 %h to ${sz}`, - ` %i0 = and ${sz} %hi, %mask`, - ` br label %probe.loop`, - `probe.loop:`, - ` %pi = phi ${sz} [ %i0, %probe ], [ %pi.next, %probe.next ]`, - ` %bep = getelementptr inbounds i32, ptr %bk, ${sz} %pi`, - ` %b = load i32, ptr %bep`, - ` %bempty = icmp eq i32 %b, 0`, - ` br i1 %bempty, label %miss, label %probe.tag`, - `probe.tag:`, - ` %bt = and i32 %b, -16777216`, - ` %btq = icmp eq i32 %bt, %tag`, - ` br i1 %btq, label %probe.entry, label %probe.next`, - `probe.entry:`, - ` %bix = and i32 %b, 16777215`, - ` %bix1 = sub i32 %bix, 1`, - sz === "i32" ? ` %bi = add i32 %bix1, 0` : ` %bi = zext i32 %bix1 to ${sz}`, - ` %pe = getelementptr inbounds %ScrMapEntry, ptr %ents, ${sz} %bi`, - ` %peh = getelementptr inbounds %ScrMapEntry, ptr %pe, i32 0, i32 2`, - ` %ph = load i64, ptr %peh`, - ` %pk = load i64, ptr %pe`, - ` %phq = icmp eq i64 %ph, %h`, - ` %pkq = icmp eq i64 %pk, %kb`, - ` %pq = and i1 %phq, %pkq`, - ` br i1 %pq, label %probe.found, label %probe.next`, - `probe.found:`, - ` ret ptr %pe`, - `probe.next:`, - ` %pi.inc = add ${sz} %pi, 1`, - ` %pi.next = and ${sz} %pi.inc, %mask`, - ` br label %probe.loop`, - `miss:`, - ` ret ptr null`, - `runtime:`, - ` %r = call ptr @scr_map_entry_f64(ptr %m, double %k) memory(read) nounwind willreturn`, - ` ret ptr %r`, - `}`, - ``, - ); - } - // Map/Set iteration steps read the entries array inline: an index is - // live when it is below the entry count and its entry's cached hash is - // nonzero (a tombstone's is zero), as scr_map_iter_live decides. The - // entry count is read again at every step, so entries appended during - // the iteration are visited. - if (this.decls.has(MAP_ITER_DECL)) { - const sz = this.sizeType; - defs.push( - `define internal ptr @sc_map_iter_entry(ptr %m, double %i) ${FN_ATTRS} {`, - `entry:`, - ` %np = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, - ` %n = load i32, ptr %np`, - ` %nf = uitofp i32 %n to double`, - ` %lo = fcmp oge double %i, 0.0`, - ` %hi = fcmp olt double %i, %nf`, - ` %in = and i1 %lo, %hi`, - ` br i1 %in, label %load, label %none`, - `load:`, - ` %ix = fptoui double %i to ${sz}`, - ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`, - ` %entries = load ptr, ptr %ep`, - ` %e = getelementptr inbounds %ScrMapEntry, ptr %entries, ${sz} %ix`, - ` %hp = getelementptr inbounds %ScrMapEntry, ptr %e, i32 0, i32 2`, - ` %h = load i64, ptr %hp`, - ` %live = icmp ne i64 %h, 0`, - ` br i1 %live, label %yes, label %none`, - `yes:`, - ` ret ptr %e`, - `none:`, - ` ret ptr null`, - `}`, - ``, - `define internal double @sc_map_iter_count(ptr %m) ${FN_ATTRS} {`, - `entry:`, - ` %np = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 8`, - ` %n = load i32, ptr %np`, - ` %nf = uitofp i32 %n to double`, - ` ret double %nf`, - `}`, - ``, - ); - } - // String-key Map/Set lookups test the map's absent-key filter inline - // (scr_map.c scr_map_filter_bits): a key whose two filter bits are not - // both set is absent, so most misses on small and medium maps return - // without a call. On 64-bit targets the key's cached hash h is the high - // half of its capacity word, the runtime's widened hash is (h << 32) | h, - // and the filter reads its bits 44..55, which are the capacity word's - // own. A key without a cached hash, and every possible hit, take the - // runtime lookup. - if (this.decls.has(STRING_MAP_ENTRY_DECL)) { - defs.push( - `define internal ptr @sc_map_entry_str(ptr %m, ptr %k) ${FN_ATTRS} {`, - `entry:`, - ...(this.sizeType === "i64" - ? [ - ` %fp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10`, - ` %filter = load i64, ptr %fp`, - ` %capp = getelementptr inbounds %ScrStr, ptr %k, i32 0, i32 2`, - ` %cap = load i64, ptr %capp`, - ` %cached = icmp uge i64 %cap, 4294967296`, - ` br i1 %cached, label %test, label %slow`, - `test:`, - ` %s1 = lshr i64 %cap, 44`, - ` %i1 = and i64 %s1, 63`, - ` %b1 = shl i64 1, %i1`, - ` %s2 = lshr i64 %cap, 50`, - ` %i2 = and i64 %s2, 63`, - ` %b2 = shl i64 1, %i2`, - ` %bits = or i64 %b1, %b2`, - ` %have = and i64 %filter, %bits`, - ` %maybe = icmp eq i64 %have, %bits`, - ` br i1 %maybe, label %slow, label %absent`, - `absent:`, - ` ret ptr null`, - ] - : [` br label %slow`]), - `slow:`, - ` %r = call ptr @scr_map_entry_str(ptr %m, ptr %k)`, - ` ret ptr %r`, - `}`, - ``, - ); - } - // String comparisons enter through small internal wrappers that LLVM - // inlines at each site: interned literals and shared values compare by - // identity, and equality rejects different byte lengths, before any - // runtime call (scr_str_eq/scr_str_cmp keep the complete semantics). - if (this.decls.has(`declare zeroext i1 @scr_str_eq(ptr, ptr)`)) { - defs.push( - `define internal zeroext i1 @sc_str_eq(ptr %a, ptr %b) ${FN_ATTRS} {`, - `entry:`, - ` %same = icmp eq ptr %a, %b`, - ` br i1 %same, label %yes, label %lengths`, - `lengths:`, - ` %alen.p = getelementptr inbounds %ScrStr, ptr %a, i32 0, i32 1`, - ` %blen.p = getelementptr inbounds %ScrStr, ptr %b, i32 0, i32 1`, - ` %alen = load ${this.sizeType}, ptr %alen.p`, - ` %blen = load ${this.sizeType}, ptr %blen.p`, - ` %samelen = icmp eq ${this.sizeType} %alen, %blen`, - ` br i1 %samelen, label %bytes, label %no`, - `bytes:`, - ` %eq = call zeroext i1 @scr_str_eq(ptr %a, ptr %b)`, - ` ret i1 %eq`, - `yes:`, - ` ret i1 true`, - `no:`, - ` ret i1 false`, - `}`, - ``, - ); - } - for (const [text, name] of this.literalEqualities) { - defs.push( - ...literalEqualityDefinition( - name, - wtf8Bytes(text), - this.internLiteral(text), - this.sizeType, - FN_ATTRS, - ), - ``, - ); - } - // String length and charCodeAt read proven-ASCII strings inline: bit 31 - // of the capacity word (SCR_STR_ASCII_BIT, the low half's sign bit on - // little-endian targets) makes UTF-16 units bytes. Unproven strings and - // anything but an in-range index take the runtime's UTF-16 mapping. - // A scan's receiver reads inline too where runtime state is ordinary - // global storage (ScrStrScan; SCR_TL makes it thread-local otherwise). - const scanInline = this.mod.workers !== true && this.mod.lib?.threadInstances !== true; - let scanDeclared = false; - for (const helper of STRING_READ_HELPERS) { - if (!this.decls.has(helper.decl)) continue; - if (scanInline && !scanDeclared) { - defs.push(stringScanDecl(this.sizeType), ``); - scanDeclared = true; - } - defs.push(...helper.define(this.sizeType, FN_ATTRS, scanInline), ``); - } - // Ordering decides inline from the first differing bytes. The bytes - // before them are equal, so both strings are at the same position of a - // character: either both bytes continue characters with the same lead - // byte, or both start a character. Byte order is the UTF-16 order for - // two continuation bytes, and for two lead bytes when one starts a - // character below U+D000, whose UTF-16 unit is below every unit of a - // character with a lead byte of at least 0xED. Between lead bytes of at - // least 0xED, a supplementary character (0xF0..0xF4, a high surrogate - // unit) orders below U+E000..U+FFFF (0xEE, 0xEF), and a lead byte of - // 0xED (U+D000..U+D7FF or a lone surrogate) takes the runtime. - // Differing words without a byte of at least 0xED decide in byte - // order; other differing words decide by their first differing byte, - // and strings shorter than a word by their first byte. Otherwise a - // proven-ASCII string on either side orders by bytes (scr_str_cmp), and - // other pairs call scr_str_cmp_u16_bytes, which only reads the two - // strings, so LLVM can merge repeated comparisons of one pair. Callers - // only test the sign of the result. - if (this.decls.has(`declare i32 @scr_str_cmp_u16(ptr, ptr)`)) { - const fn = "scr_str_cmp_u16"; - this.declare("declare i64 @llvm.bswap.i64(i64)"); - this.declare("declare i64 @llvm.cttz.i64(i64, i1 immarg)"); - this.declare("declare i8 @llvm.umin.i8(i8, i8)"); - this.declare("declare i32 @scr_str_cmp(ptr, ptr)"); - this.declare("declare i32 @scr_str_cmp_u16_bytes(ptr, ptr)"); - defs.push( - `define internal i32 @${fn.replace(/^scr_/, "sc_")}(ptr %a, ptr %b) ${FN_ATTRS} {`, - `entry:`, - ` %same = icmp eq ptr %a, %b`, - ` br i1 %same, label %equal, label %lengths`, - `lengths:`, - ` %alen.p = getelementptr inbounds %ScrStr, ptr %a, i32 0, i32 1`, - ` %blen.p = getelementptr inbounds %ScrStr, ptr %b, i32 0, i32 1`, - ` %alen = load ${this.sizeType}, ptr %alen.p`, - ` %blen = load ${this.sizeType}, ptr %blen.p`, - ` %aempty = icmp eq ${this.sizeType} %alen, 0`, - ` %bempty = icmp eq ${this.sizeType} %blen, 0`, - ` %empty = or i1 %aempty, %bempty`, - ` br i1 %empty, label %order, label %words`, - // Both at least 8 bytes: the first word of each. - `words:`, - ` %along = icmp uge ${this.sizeType} %alen, 8`, - ` %blong = icmp uge ${this.sizeType} %blen, 8`, - ` %long = and i1 %along, %blong`, - ` br i1 %long, label %word, label %first`, - `word:`, - ` %awp = getelementptr inbounds %ScrStr, ptr %a, i32 1`, - ` %bwp = getelementptr inbounds %ScrStr, ptr %b, i32 1`, - ` %aw = load i64, ptr %awp, align 1`, - ` %bw = load i64, ptr %bwp, align 1`, - ` %wdiffer = icmp ne i64 %aw, %bw`, - ` br i1 %wdiffer, label %differ, label %wsame`, - // Equal first words and one length of at most 16 bytes: the word - // ending at that length covers the rest, and any difference lies - // past the equal first word. - `wsame:`, - ` %samelen = icmp eq ${this.sizeType} %alen, %blen`, - ` %short = icmp ule ${this.sizeType} %alen, 16`, - ` %tailable = and i1 %samelen, %short`, - ` br i1 %tailable, label %tail, label %order`, - `tail:`, - ` %toff = sub ${this.sizeType} %alen, 8`, - ` %atp = getelementptr inbounds i8, ptr %awp, ${this.sizeType} %toff`, - ` %btp = getelementptr inbounds i8, ptr %bwp, ${this.sizeType} %toff`, - ` %at = load i64, ptr %atp, align 1`, - ` %bt = load i64, ptr %btp, align 1`, - ` %tdiffer = icmp ne i64 %at, %bt`, - ` br i1 %tdiffer, label %differ, label %equal`, - `first:`, - ` %adata = getelementptr inbounds %ScrStr, ptr %a, i32 1`, - ` %bdata = getelementptr inbounds %ScrStr, ptr %b, i32 1`, - ` %x = load i8, ptr %adata`, - ` %y = load i8, ptr %bdata`, - ` %fdiffer = icmp ne i8 %x, %y`, - ` br i1 %fdiffer, label %bytes, label %order`, - // Two differing words. When neither has a byte of at least 0xED - // (a byte b is when its high bit and bit 7 of (b & 0x7f) + 0x13 are - // set), byte-swapped little-endian words compare in byte order. - // Otherwise the lowest set byte of their xor is the first differing - // byte. - `differ:`, - ` %dx = phi i64 [ %aw, %word ], [ %at, %tail ]`, - ` %dy = phi i64 [ %bw, %word ], [ %bt, %tail ]`, - ` %xl = and i64 %dx, 9187201950435737471`, - ` %yl = and i64 %dy, 9187201950435737471`, - ` %xa = add i64 %xl, 1374463283923456787`, - ` %ya = add i64 %yl, 1374463283923456787`, - ` %xh = and i64 %dx, %xa`, - ` %yh = and i64 %dy, %ya`, - ` %hh = or i64 %xh, %yh`, - ` %whigh = and i64 %hh, -9187201950435737472`, - ` %dlow = icmp eq i64 %whigh, 0`, - ` br i1 %dlow, label %wordorder, label %locate`, - `wordorder:`, - ` %xsw = call i64 @llvm.bswap.i64(i64 %dx)`, - ` %ysw = call i64 @llvm.bswap.i64(i64 %dy)`, - ` %wbelow = icmp ult i64 %xsw, %ysw`, - ` %wsign = select i1 %wbelow, i32 -1, i32 1`, - ` ret i32 %wsign`, - `locate:`, - ` %dxor = xor i64 %dx, %dy`, - ` %dtz = call i64 @llvm.cttz.i64(i64 %dxor, i1 true)`, - ` %dshift = and i64 %dtz, 56`, - ` %dxs = lshr i64 %dx, %dshift`, - ` %dys = lshr i64 %dy, %dshift`, - ` %dxb = trunc i64 %dxs to i8`, - ` %dyb = trunc i64 %dys to i8`, - ` br label %bytes`, - // The first differing bytes. - `bytes:`, - ` %bx = phi i8 [ %x, %first ], [ %dxb, %locate ]`, - ` %by = phi i8 [ %y, %first ], [ %dyb, %locate ]`, - ` %bmin = call i8 @llvm.umin.i8(i8 %bx, i8 %by)`, - ` %blow = icmp ult i8 %bmin, -19`, - ` br i1 %blow, label %byte, label %high`, - // Both at least 0xED. A lone surrogate (0xED) takes the runtime; - // supplementary lead bytes (0xF0..0xF4) order below 0xEE and 0xEF - // once 0x10 is subtracted from them. - `high:`, - ` %xed = icmp eq i8 %bx, -19`, - ` %yed = icmp eq i8 %by, -19`, - ` %ed = or i1 %xed, %yed`, - ` br i1 %ed, label %order, label %planes`, - `planes:`, - ` %xsupp = icmp uge i8 %bx, -16`, - ` %ysupp = icmp uge i8 %by, -16`, - ` %xlow = add i8 %bx, -16`, - ` %ylow = add i8 %by, -16`, - ` %xk = select i1 %xsupp, i8 %xlow, i8 %bx`, - ` %yk = select i1 %ysupp, i8 %ylow, i8 %by`, - ` %kbelow = icmp ult i8 %xk, %yk`, - ` %ksign = select i1 %kbelow, i32 -1, i32 1`, - ` ret i32 %ksign`, - `byte:`, - ` %below = icmp ult i8 %bx, %by`, - ` %sign = select i1 %below, i32 -1, i32 1`, - ` ret i32 %sign`, - `order:`, - ` %acw.p = getelementptr inbounds %ScrStr, ptr %a, i32 0, i32 2`, - ` %bcw.p = getelementptr inbounds %ScrStr, ptr %b, i32 0, i32 2`, - ` %acw = load i32, ptr %acw.p`, - ` %bcw = load i32, ptr %bcw.p`, - ` %cws = or i32 %acw, %bcw`, - ` %anyascii = icmp slt i32 %cws, 0`, - ` br i1 %anyascii, label %memorder, label %units`, - `memorder:`, - ` %m = call i32 @scr_str_cmp(ptr %a, ptr %b)`, - ` ret i32 %m`, - `units:`, - ` %c = call i32 @scr_str_cmp_u16_bytes(ptr %a, ptr %b) nounwind willreturn memory(argmem: read)`, - ` ret i32 %c`, - `equal:`, - ` ret i32 0`, - `}`, - ``, - ); - } - // The declarations these helpers added must land in the extern block, - // which already flushed — append here instead (LLVM is order-free). - return defs.length > 0 ? [...defs] : defs; + helperDefs(): string[] { + return helperDefs(this); } /** The inline RC helpers every call site requested, emitted last so * requests from main/library epilogues are included. Slow-path * declarations a late request added after the extern block flushed land * here too. */ - private inlineRcTail(flushedDecls: ReadonlySet): string[] { + inlineRcTail(flushedDecls: ReadonlySet): string[] { const late = inlineRcDecls(this.shapeHost).filter((d) => !flushedDecls.has(d)); // NULL-skipping retain wrappers of nullable unions; a runtime inner // retain requested after the extern block flushed is declared here. @@ -3857,7 +1591,7 @@ export class LlEmitter { /** Env-signature wrappers + interned immortal closures for declared * functions used as values (the runtime ABI’s sc_w_/sc_fc_ pair): every * mention of `f` yields the same pointer, so `f === f` holds. */ - private emitFnValueDefs(): string[] { + emitFnValueDefs(): string[] { const out: string[] = []; for (const name of this.fnValues) { const fn = this.fnByName.get(name)!; @@ -3893,74 +1627,12 @@ export class LlEmitter { } return out; } - - /** The argument-pack ABI and trampoline prefix shared by async functions - * and generators. Their promise/generator completion and spawn tails stay - * with the callers below. */ - private emitArgPackAndTrampolinePrologue(fn: IrFunction): LlArgPackAndTrampolinePrologue { - const pack = mangleArgPack(fn.name); - const lifted = fn.captures !== undefined; - const fieldTys = [...(lifted ? ["ptr"] : []), ...fn.params.map((p) => this.llType(p.type))]; - const definitions = [`%${pack} = type { ${fieldTys.join(", ") || "i8"} } ; ${fn.name} args`]; - const sizeOf = `ptrtoint (ptr getelementptr (%${pack}, ptr null, i32 1) to ${this.sizeType})`; - - // Async argument packs live from the spawn to the trampoline's first - // instructions, so they take the runtime's small-block allocator. - // Generator packs keep malloc: the runtime frees never-started ones. - const smallPack = fn.generator === undefined; - const packAlloc = smallPack ? "scr_rt_calloc" : "malloc"; - const packFree = smallPack ? "scr_rt_free" : "free"; - this.declare(`declare void @${packFree}(ptr)`); - this.declare(`declare ptr @${packAlloc}(${this.sizeType})`); - - const tr: string[] = [ - `define internal void @${mangleTrampoline(fn.name)}(ptr %self, ptr %ap) ${FN_ATTRS} {`, - `entry:`, - ]; - const loads: string[] = []; - fieldTys.forEach((ty, i) => { - tr.push( - ` %fp${i} = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${i}`, - ` %a${i} = load ${ty}, ptr %fp${i}`, - ); - loads.push(`${ty} %a${i}`); - }); - tr.push(` call void @${packFree}(ptr %ap)`); - const ret = fn.returnType; - const retTy = this.llType(ret); - const bodyCall = `call ${retTy} @${mangleFunction(fn.name)}(${loads.join(", ")})`; - tr.push(retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`); - if (lifted && !this.coroLowered(fn)) { - tr.push(` call void @scr_closure_release(ptr %a0)`); - } - - const spawnParams = fieldTys.map((ty, i) => `${ty} %a${i}`); - const argPackLines = [ - ` %ap = call ptr @${packAlloc}(${this.sizeType} ${sizeOf})`, - ` %isnull = icmp eq ptr %ap, null`, - ` br i1 %isnull, label %oom, label %ok`, - `oom:`, - ` call void @sc_oom()`, - ` unreachable`, - `ok:`, - ]; - if (lifted) { - // scr_closure_retain is a header static inline — the `_v` twin is - // the exported symbol. - argPackLines.push(` %env = call ptr @scr_closure_retain_v(ptr %a0)`); - } - fieldTys.forEach((ty, i) => { - const src = lifted && i === 0 ? "%env" : `%a${i}`; - argPackLines.push( - ` %sp${i} = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${i}`, - ` store ${ty} ${src}, ptr %sp${i}`, - ); - }); - return { definitions, pack, lifted, fieldTys, ret, tr, spawnParams, argPackLines }; + emitArgPackAndTrampolinePrologue(fn: IrFunction): LlArgPackAndTrampolinePrologue { + return emitArgPackAndTrampolinePrologue(this, fn); } /** Settles `promise` from a clean async body result `%r` (moved in). */ - private asyncFulfillLines(ret: IrType, promise: string): string[] { + asyncFulfillLines(ret: IrType, promise: string): string[] { switch (ret.kind) { case "void": this.declare(`declare void @scr_promise_fulfill_void(ptr)`); @@ -3987,34 +1659,12 @@ export class LlEmitter { } } } - - /** Async functions whose calls can skip the fiber (scr_async_inline_*). - * A body qualifies only when nothing it runs can park its execution - * context: its emitted text, and the text of every module function it - * mentions (transitively), contains no suspension entry point. Such a - * body always completes before the call returns, which is exactly the - * behavior of a fiber that never suspends. Excluded conservatively: - * wasm32 (its coroutine lowering owns async bodies), module-evaluation - * bodies (their promise caches), - * `dyn` results (resolution may adopt a thenable), and every program - * that uses node:test subtests — `t.test()` runs a subtest inline and - * awaits it on the CURRENT fiber even from synchronous code. Worker - * programs keep fibers too: their fiber epilogue owns context - * termination (a stopping worker rejects and observes the promise), which - * the fiberless wrapper does not reproduce. A missed suspension still - * cannot reorder execution undetected: the runtime traps when a context parks while a - * fiberless body is on its stack. */ - private fiberlessAsyncFunctions(): Set { - const parks = this.parkingFunctions(); - const eligible = parks === null ? new Set() : this.neverParkingAsync(parks); - for (const name of this.inlineAwaitCallees) - if (!eligible.has(name)) - throw new InternalCompilerError(`llvm emitter bug: inline await of suspending ${name}`); - return eligible; + fiberlessAsyncFunctions(): Set { + return fiberlessAsyncFunctions(this); } /** The async functions `parks` leaves fiberless (fiberlessAsyncFunctions). */ - private neverParkingAsync(parks: ReadonlySet): Set { + neverParkingAsync(parks: ReadonlySet): Set { const eligible = new Set(); for (const fn of this.mod.functions) { if (fn.async !== true || fn.generator !== undefined) continue; @@ -4025,50 +1675,14 @@ export class LlEmitter { } return eligible; } - - /** The module functions whose bodies may park their execution context: - * their emitted text mentions a suspension entry point (SUSPENDS), or - * they mention such a function (transitively). Null where async bodies - * must keep fibers regardless: wasm32 (its coroutine lowering owns async - * bodies), worker programs, and programs that use node:test subtests, - * whose `t.test()` awaits on the CURRENT fiber even from synchronous - * code. */ - private parkingFunctions(): Set | null { - if (this.wasi || this.mod.workers === true) return null; - for (const decl of this.decls) if (/@scr_test_sub\b/.test(decl)) return null; - for (const text of this.fnDefText.values()) if (/@scr_test_sub\b/.test(text)) return null; - // Reverse mention edges, then propagate "may park" to every mentioner. - const mentionedBy = new Map>(); - const parks = new Set(); - const work: string[] = []; - for (const [name, text] of this.fnDefText) { - if (SUSPENDS.test(text)) { - parks.add(name); - work.push(name); - } - for (const callee of this.mentionedFunctions(text)) { - if (callee === name) continue; - let callers = mentionedBy.get(callee); - if (callers === undefined) mentionedBy.set(callee, (callers = new Set())); - callers.add(name); - } - } - while (work.length > 0) { - const callers = mentionedBy.get(work.pop()!); - if (callers === undefined) continue; - for (const caller of callers) { - if (parks.has(caller)) continue; - parks.add(caller); - work.push(caller); - } - } - return parks; + parkingFunctions(): Set | null { + return parkingFunctions(this); } private mangledFunctions: Map | null = null; /** The module functions whose ordinary or borrowed body `text` mentions. */ - private *mentionedFunctions(text: string): Generator { + *mentionedFunctions(text: string): Generator { if (this.mangledFunctions === null) { this.mangledFunctions = new Map(); for (const fn of this.mod.functions) { @@ -4095,7 +1709,7 @@ export class LlEmitter { * otherwise the fiber form stays. Bodies that never suspend stay * fiberless, and module evaluation bodies and `dyn` results keep fibers * as they do there. The runtime traps if a stackless body ever parks. */ - private selectStacklessAsync(fnDefs: string[]): void { + selectStacklessAsync(fnDefs: string[]): void { if (!this.stacklessAsyncAllowed) return; const parks = this.parkingFunctions(); if (parks === null) return; @@ -4129,7 +1743,7 @@ export class LlEmitter { * caller's stack between scr_async_inline_enter/leave and settles the * fresh promise from its outcome — the fiber trampoline's epilogue, * without the fiber. */ - private emitFiberlessAsyncSpawn(fn: IrFunction, spawnParams: string[]): string[] { + emitFiberlessAsyncSpawn(fn: IrFunction, spawnParams: string[]): string[] { const ret = fn.returnType; const retTy = this.llType(ret); const lifted = fn.captures !== undefined; @@ -4194,7 +1808,7 @@ export class LlEmitter { * body on the caller's stack between scr_async_inline_enter/exit, as in * its spawn wrapper, returning the result and leaving a throw pending for * the awaiting coroutine (emitCoroInlineAwait). */ - private emitAsyncInlineAwaitCall(fn: IrFunction): string[] { + emitAsyncInlineAwaitCall(fn: IrFunction): string[] { const retTy = this.llType(fn.returnType); const params = fn.params.map((p, i) => `${this.llType(p.type)} %a${i}`); const args = params.join(", "); @@ -4249,506 +1863,23 @@ export class LlEmitter { this.emitPendingCheck(); return value; } - - /** The await-call entry of an async function (`sc_aw_*`): one direct - * `await f(...)` as a call returning the awaited value, so the call site - * needs no promise of its own. A fiberless body runs on the caller's - * stack as in its spawn wrapper; a clean result then takes the await's - * one microtask turn (scr_await_hop) and is returned without ever - * materializing the promise, which nothing else could observe. A throw - * builds the rejected promise exactly as the spawn wrapper would and - * awaits it, so the rejection reaches the awaiter on the same turn. Other - * bodies pack their arguments as the spawn wrapper does and enter - * scr_async_spawn_await_*, which parks the caller on the promise as soon - * as the body first suspends. The adopting entry (`sc_ad_*`, for - * `return f(...)` in an async function) adds the turn of the then job - * that adopts the returned promise, before the await proper. */ - private emitAsyncAwaitCall( - fn: IrFunction, - argPackLines: string[] | null, - adopt = false, - ): string[] { - const ret = fn.returnType; - const retTy = this.llType(ret); - const params = fn.params.map((p, i) => `${this.llType(p.type)} %a${i}`); - const awaitLine = (dest: string, promise: string): string => { - switch (ret.kind) { - case "void": - this.declare(`declare void @scr_await_void(ptr)`); - return ` call void @scr_await_void(ptr ${promise})`; - case "f64": - case "date": - this.declare(`declare double @scr_await_f64(ptr)`); - return ` ${dest} = call double @scr_await_f64(ptr ${promise})`; - case "bool": - this.declare(`declare zeroext i1 @scr_await_bool(ptr)`); - return ` ${dest} = call zeroext i1 @scr_await_bool(ptr ${promise})`; - case "string": - this.declare(`declare ptr @scr_await_str(ptr)`); - return ` ${dest} = call ptr @scr_await_str(ptr ${promise})`; - default: - this.declare(`declare ptr @scr_await_ref(ptr)`); - return ` ${dest} = call ptr @scr_await_ref(ptr ${promise})`; - } - }; - const retLine = (v: string): string => - retTy === "void" ? ` ret void` : ` ret ${retTy} ${v}`; - const head = adopt - ? `define internal ${retTy} @${mangleAsyncAdoptCall(fn.name)}(${params.join(", ")}) ${FN_ATTRS} { ; adopt ${fn.name}` - : `define internal ${retTy} @${mangleAsyncAwaitCall(fn.name)}(${params.join(", ")}) ${FN_ATTRS} { ; await ${fn.name}`; - const args = params.join(", "); - if (adopt && argPackLines !== null) { - // A body that may suspend settles later than the adoption's then job - // runs: materialize its promise, take that turn, then await it. - this.declare(`declare void @scr_await_hop()`); - this.declare(`declare void @scr_promise_release(ptr)`); - return [ - head, - `entry:`, - ` %p = call ptr @${mangleAsyncSpawn(fn.name)}(${args})`, - ` call void @scr_await_hop()`, - awaitLine("%v", "%p"), - ` call void @scr_promise_release(ptr %p)`, - retLine("%v"), - `}`, - ``, - ]; - } - if (argPackLines !== null && this.stacklessAsync.has(fn.name)) { - // A stackless body's eager prefix already runs on the awaiter's - // stack, which then awaits the promise like the two separate steps. - this.declare(`declare void @scr_promise_release(ptr)`); - return [ - head, - `entry:`, - ` %p = call ptr @${mangleAsyncSpawn(fn.name)}(${args})`, - awaitLine("%v", "%p"), - ` call void @scr_promise_release(ptr %p)`, - retLine("%v"), - `}`, - ``, - ]; - } - if (argPackLines !== null) { - const kind = - ret.kind === "void" - ? "void" - : ret.kind === "f64" || ret.kind === "date" - ? "f64" - : ret.kind === "bool" - ? "bool" - : ret.kind === "string" - ? "str" - : "ref"; - const cret = kind === "bool" ? "zeroext i1" : retTy; - this.declare(`declare ${cret} @scr_async_spawn_await_${kind}(ptr, ptr)`); - this.needOom(); - const call = `call ${cret} @scr_async_spawn_await_${kind}(ptr @${mangleTrampoline(fn.name)}, ptr %ap)`; - return [ - head, - `entry:`, - ...argPackLines, - kind === "void" ? ` ${call}` : ` %v = ${call}`, - retLine("%v"), - `}`, - ``, - ]; - } - this.declare(`declare void @scr_async_inline_enter(ptr)`); - this.declare(`declare void @scr_async_inline_exit(ptr)`); - this.declare(`declare void @scr_await_hop()`); - this.declare(`declare ptr @scr_promise_new()`); - this.declare(`declare void @scr_promise_reject_pending(ptr)`); - this.declare(`declare void @scr_promise_release(ptr)`); - const bodyCall = `call ${retTy} @${mangleFunction(fn.name)}(${args})`; - const out = [ - head, - `entry:`, - ` %frame = alloca { ptr, ptr }`, - ` call void @scr_async_inline_enter(ptr %frame)`, - retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`, - ...this.pendingTestLines("%pend"), - ` call void @scr_async_inline_exit(ptr %frame)`, - // Adopting: the then job's turn comes before the settled promise's - // own reaction, whichever way the body completed. - ...(adopt ? [` call void @scr_await_hop()`] : []), - ` br i1 %pend, label %thrown, label %clean`, - `clean:`, - ` call void @scr_await_hop()`, - retLine("%r"), - `thrown:`, - ]; - if (ret.kind !== "void" && isRefCounted(ret)) { - // An escaping throw means %r is the never-read dummy (NULL). - out.push(` call void ${releaseSym(this.shapeHost, ret)}(ptr %r)`); - } - out.push( - ` %p = call ptr @scr_promise_new()`, - ` call void @scr_promise_reject_pending(ptr %p)`, - awaitLine("%v", "%p"), - ` call void @scr_promise_release(ptr %p)`, - retLine("%v"), - `}`, - ``, - ); - return out; + emitAsyncAwaitCall(fn: IrFunction, argPackLines: string[] | null, adopt = false): string[] { + return emitAsyncAwaitCall(this, fn, argPackLines, adopt); } - - /** Per-async-function machinery — async.ts's scaffolding, .ll - * flavored: an argument-pack struct type, a fiber trampoline (unpacks, - * frees the pack, runs the ordinary compiled body, settles the - * promise — fulfilling on clean return, leaving a pending exception - * for the runtime to reject with), and a spawn wrapper call sites and - * closures enter through (packs the args +1, scr_async_spawn runs the - * fiber eagerly to its first suspension and returns the promise). */ - private emitAsyncScaffolding(): string[] { - const out: string[] = []; - if (this.wasi) { - this.declare(`declare void @llvm.coro.resume(ptr)`); - this.declare(`declare void @llvm.coro.destroy(ptr)`); - out.push( - `define void @scr_wasi_coro_resume(ptr %handle) ${FN_ATTRS} {`, - `entry:`, - ` call void @llvm.coro.resume(ptr %handle)`, - ` ret void`, - `}`, - `define void @scr_wasi_coro_destroy(ptr %handle) ${FN_ATTRS} {`, - `entry:`, - ` call void @llvm.coro.destroy(ptr %handle)`, - ` ret void`, - `}`, - ``, - ); - } - const fiberless = this.fiberlessAsyncFunctions(); - for (const fn of this.mod.functions) { - if (fn.async !== true || fn.generator !== undefined) continue; - if (fiberless.has(fn.name) && this.awaitCallees.has(fn.name)) { - for (const line of this.emitAsyncAwaitCall(fn, null)) out.push(line); - } - if (fiberless.has(fn.name) && this.adoptCallees.has(fn.name)) { - for (const line of this.emitAsyncAwaitCall(fn, null, true)) out.push(line); - } - if (this.inlineAwaitCallees.has(fn.name)) { - for (const line of this.emitAsyncInlineAwaitCall(fn)) out.push(line); - } - if (fiberless.has(fn.name)) { - const fieldTys = [ - ...(fn.captures !== undefined ? ["ptr"] : []), - ...fn.params.map((p) => this.llType(p.type)), - ]; - const params = fieldTys.map((ty, i) => `${ty} %a${i}`); - for (const line of this.emitFiberlessAsyncSpawn(fn, params)) out.push(line); - continue; - } - const { definitions, ret, tr, spawnParams, argPackLines } = - this.emitArgPackAndTrampolinePrologue(fn); - for (const line of definitions) out.push(line); - if (this.awaitCallees.has(fn.name)) { - for (const line of this.emitAsyncAwaitCall(fn, argPackLines)) out.push(line); - } - if (this.adoptCallees.has(fn.name)) { - for (const line of this.emitAsyncAwaitCall(fn, argPackLines, true)) out.push(line); - } - const stackless = this.stacklessAsync.has(fn.name); - const spawn = stackless ? "scr_async_spawn_stackless" : "scr_async_spawn"; - this.declare(`declare ptr @scr_fiber_promise(ptr)`); - this.declare(`declare ptr @${spawn}(ptr, ptr)`); - this.needOom(); - if (this.wasi || stackless) { - // The coroutine body settles its own promise and owns the retained - // lifted environment until final suspension. Its initial call - // returns here at the first suspend (or final suspend). - tr.push(` ret void`, `}`, ``); - } else { - if (fn.captures !== undefined) { - this.declare(`declare void @scr_closure_release(ptr)`); - } - tr.push( - ...this.pendingTestLines("%pend"), - ` br i1 %pend, label %thrown, label %clean`, - `clean:`, - ` %pr = call ptr @scr_fiber_promise(ptr %self)`, - ); - for (const line of this.asyncFulfillLines(ret, "%pr")) tr.push(line); - tr.push(` ret void`, `thrown:`); - if (ret.kind !== "void" && isRefCounted(ret)) { - // An escaping throw means %r is the never-read dummy (NULL). - tr.push(` call void ${releaseSym(this.shapeHost, ret)}(ptr %r)`); - } - tr.push(` ret void`, `}`, ``); - } - for (const line of tr) out.push(line); - - // Spawn wrapper: pack the args (+1 moves in), spawn the fiber. - const cache = fn.asyncCacheGlobal !== undefined ? mangleGlobal(fn.asyncCacheGlobal) : null; - const cycleCache = - fn.asyncCycleCacheGlobal !== undefined ? mangleGlobal(fn.asyncCycleCacheGlobal) : null; - if (cache !== null || cycleCache !== null) { - this.declare(`declare ptr @scr_promise_retain_v(ptr)`); - this.declare(`declare void @scr_promise_release(ptr)`); - } - if (cache !== null) { - this.declare(`declare void @scr_promise_mark_handled(ptr)`); - } - if (fn.captures !== undefined) { - this.declare(`declare ptr @scr_closure_retain_v(ptr)`); - } - const sp: string[] = [ - `define internal ptr @${mangleAsyncSpawn(fn.name)}(${spawnParams.join(", ")}) ${FN_ATTRS} { ; spawn ${fn.name}`, - `entry:`, - ...(cache !== null - ? [ - ` %cached = load ptr, ptr @${cache}`, - ` %cache_hit = icmp ne ptr %cached, null`, - ` br i1 %cache_hit, label %cached_return, label %cache_miss`, - `cached_return:`, - ` %cached_owned = call ptr @scr_promise_retain_v(ptr %cached)`, - ` ret ptr %cached_owned`, - `cache_miss:`, - ] - : []), - ...argPackLines, - ]; - sp.push( - ` %p = call ptr @${spawn}(ptr @${mangleTrampoline(fn.name)}, ptr %ap)`, - ...(cache !== null - ? [ - // The module loader owns this evaluation promise - // immediately. A later sibling can throw before the - // aggregate dependency wait is built, but this rejection - // must never become an unrelated unhandled rejection. - ` call void @scr_promise_mark_handled(ptr %p)`, - ] - : []), - ...(cache !== null - ? [ - ` %cache_owned = call ptr @scr_promise_retain_v(ptr %p)`, - // The eager spawn may have re-entered this guarded module - // through an admitted async cycle and installed a temporary - // cache entry. Drop that owned slot before replacing it - // with the outer evaluation promise. - ` %replaced_cache = load ptr, ptr @${cache}`, - ` call void @scr_promise_release(ptr %replaced_cache)`, - ` store ptr %cache_owned, ptr @${cache}`, - ] - : []), - ...(cycleCache !== null - ? [ - // Eager recursive spawns publish from the inside out. The - // runtime-requested outermost member writes last and is the - // SCC's actual evaluation root. - ` %cycle_cache_owned = call ptr @scr_promise_retain_v(ptr %p)`, - ` %replaced_cycle_cache = load ptr, ptr @${cycleCache}`, - ` call void @scr_promise_release(ptr %replaced_cycle_cache)`, - ` store ptr %cycle_cache_owned, ptr @${cycleCache}`, - ] - : []), - ` ret ptr %p`, - `}`, - ``, - ); - for (const line of sp) out.push(line); - } - for (const line of this.emitGenScaffolding()) out.push(line); - return out; + emitAsyncScaffolding(): string[] { + return emitAsyncScaffolding(this); } /** Whether `fn`'s body is emitted as an LLVM switched coroutine: every * wasm32 async or generator body, native synchronous generators, and the * native async bodies selectStacklessAsync chose. */ - private coroLowered(fn: IrFunction): boolean { + coroLowered(fn: IrFunction): boolean { if (this.wasi) return fn.async === true || fn.generator !== undefined; if (fn.async === true) return fn.generator === undefined && this.stacklessAsync.has(fn.name); return this.stacklessGenerators && fn.generator !== undefined; } - - /** Per-generator-function machinery — the async scaffolding's lazy - * sibling (async.ts's emitGenScaffolding): the same argument pack, - * a fiber trampoline whose epilogue stores the COMPLETION value (or - * consumes the GENRET sentinel, promoting the parked .return value), a - * spawn wrapper that only ALLOCATES the suspended fiber, and the - * never-started teardown that drops the packed (+1) arguments. */ - private emitGenScaffolding(): string[] { - const out: string[] = []; - for (const fn of this.mod.functions) { - if (fn.generator === undefined) continue; - const { definitions, pack, lifted, fieldTys, ret, tr, spawnParams, argPackLines } = - this.emitArgPackAndTrampolinePrologue(fn); - for (const line of definitions) out.push(line); - this.declare(`declare zeroext i1 @scr_exc_genret_pending()`); - this.declare(`declare void @scr_exc_clear()`); - this.declare(`declare ptr @scr_gen_of_fiber(ptr)`); - this.declare(`declare void @scr_gen_ret_to_out(ptr)`); - this.declare(`declare ptr @scr_gen_new(ptr, ptr, ptr)`); - this.needOom(); - const coroBody = this.coroLowered(fn); - if (lifted && !coroBody) { - this.declare(`declare void @scr_closure_release(ptr)`); - } - // Normal completion stores the (typed) return value; void completes - // with the NONE slot — JS's undefined done-value. A GENRET unwind - // consumes the sentinel and promotes the parked .return value; a - // real exception stays pending (the consumer-side resume moves it). - if (coroBody) { - tr.push(` ret void`, `}`, ``); - } else { - tr.push( - ` %g = call ptr @scr_gen_of_fiber(ptr %self)`, - ...this.pendingTestLines("%pend"), - ` br i1 %pend, label %thrown, label %clean`, - `clean:`, - ); - switch (ret.kind) { - case "void": - tr.push(` br label %done ; void body: the done value is undefined (NONE)`); - break; - case "f64": - case "date": - this.declare(`declare void @scr_gen_out_f64(ptr, double)`); - tr.push(` call void @scr_gen_out_f64(ptr %g, double %r)`, ` br label %done`); - break; - case "bool": - this.declare(`declare void @scr_gen_out_bool(ptr, i1 zeroext)`); - tr.push(` call void @scr_gen_out_bool(ptr %g, i1 %r)`, ` br label %done`); - break; - default: { - const v = vAdapters(this.shapeHost, ret); - this.declare(`declare void @scr_gen_out_ref(ptr, ptr, ptr)`); - tr.push( - ` call void @scr_gen_out_ref(ptr %g, ptr %r, ptr ${v.release})`, - ` br label %done`, - ); - } - } - tr.push( - `thrown:`, - ` %genret = call zeroext i1 @scr_exc_genret_pending()`, - ` br i1 %genret, label %promote, label %dropdummy`, - `promote:`, - ` call void @scr_exc_clear()`, - ` call void @scr_gen_ret_to_out(ptr %g)`, - ` br label %dropdummy`, - `dropdummy:`, - ); - if (ret.kind !== "void" && isRefCounted(ret)) { - tr.push( - ` call void ${releaseSym(this.shapeHost, ret)}(ptr %r) ; unwound: the never-read dummy`, - ); - } - tr.push(` br label %done`, `done:`, ` ret void`, `}`, ``); - } - for (const line of tr) out.push(line); - - let settleAsync: string | null = null; - if (fn.async) { - this.declare(`declare ptr @scr_async_gen_new(ptr, ptr, ptr, ptr)`); - const genT: IrType & { kind: "generator" } = { - kind: "generator", - async: true, - yieldT: fn.generator.yieldT, - retT: ret, - nextT: fn.generator.nextT, - }; - const resultT = fn.generator.resultType; - const build = this.genResultThunkFor(genT, resultT); - settleAsync = `${build}_async`; - const settleKey = `ags:${typeKey(genT)}`; - if (!this.resolveThunks.has(settleKey)) { - this.resolveThunks.set(settleKey, settleAsync); - const adapters = vAdapters(this.shapeHost, resultT); - this.resolveThunkDefs.push( - `define internal void @${settleAsync}(ptr %g, ptr %p) ${FN_ATTRS} {`, - `entry:`, - ` %r = call ptr @${build}(ptr %g)`, - ` call void @scr_promise_fulfill_ref(ptr %p, ptr %r, ptr ${adapters.retain}, ptr ${adapters.release}, ptr ${traceArg(this.shapeHost, resultT)})`, - ` ret void`, - `}`, - ``, - ); - } - this.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); - } - - // The never-started teardown: drop the packed (+1) arguments. - const dr: string[] = [ - `define internal void @${mangleGenDrop(fn.name)}(ptr %ap) ${FN_ATTRS} {`, - `entry:`, - ]; - fieldTys.forEach((ty, i) => { - const pType = lifted && i === 0 ? null : fn.params[lifted ? i - 1 : i]!.type; - const refcounted = pType === null || isRefCounted(pType); - if (!refcounted) return; - dr.push( - ` %dp${i} = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${i}`, - ` %dv${i} = load ptr, ptr %dp${i}`, - ); - if (pType === null) { - this.declare(`declare void @scr_closure_release(ptr)`); - dr.push(` call void @scr_closure_release(ptr %dv${i})`); - } else { - dr.push(` call void ${releaseSym(this.shapeHost, pType)}(ptr %dv${i})`); - } - }); - dr.push(` call void @free(ptr %ap)`, ` ret void`, `}`, ``); - for (const line of dr) out.push(line); - - // An iterator helper's return() before its first next() closes the - // source generator in its first argument slot. - const closesSource = fn.generator.closesSource === true; - if (fn.generator.tag !== undefined) this.declare(`declare void @scr_gen_set_tag(ptr, ptr)`); - if (fn.generator.inheritsTag === true) - this.declare(`declare void @scr_gen_copy_tag(ptr, ptr)`); - if (closesSource) { - const field = lifted ? 1 : 0; - this.declare(`declare void @scr_gen_close(ptr)`); - this.declare(`declare void @scr_gen_set_unstarted_return(ptr, ptr)`); - out.push( - `define internal void @${mangleGenSourceClose(fn.name)}(ptr %ap) ${FN_ATTRS} {`, - `entry:`, - ` %sp = getelementptr inbounds %${pack}, ptr %ap, i64 0, i32 ${field}`, - ` %sv = load ptr, ptr %sp`, - ` call void @scr_gen_close(ptr %sv)`, - ` ret void`, - `}`, - ``, - ); - } - - // Spawn wrapper: pack the args (+1 moves in), allocate the - // SUSPENDED fiber — nothing runs until the first .next(). - if (lifted) { - this.declare(`declare ptr @scr_closure_retain_v(ptr)`); - } - const sp: string[] = [ - `define internal ptr @${mangleGenSpawn(fn.name)}(${spawnParams.join(", ")}) ${FN_ATTRS} { ; gen spawn ${fn.name}`, - `entry:`, - ...argPackLines, - ]; - const genNew = coroBody && !this.wasi ? "scr_gen_new_stackless" : "scr_gen_new"; - if (genNew !== "scr_gen_new") this.declare(`declare ptr @${genNew}(ptr, ptr, ptr)`); - sp.push( - settleAsync === null - ? ` %gg = call ptr @${genNew}(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)})` - : ` %gg = call ptr @scr_async_gen_new(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)}, ptr @${settleAsync})`, - ...(fn.generator.tag !== undefined - ? [` call void @scr_gen_set_tag(ptr %gg, ptr ${this.cstr(fn.generator.tag)})`] - : []), - ...(fn.generator.inheritsTag === true - ? [` call void @scr_gen_copy_tag(ptr %gg, ptr %a${lifted ? 1 : 0})`] - : []), - ...(closesSource - ? [ - ` call void @scr_gen_set_unstarted_return(ptr %gg, ptr @${mangleGenSourceClose(fn.name)})`, - ] - : []), - ` ret ptr %gg`, - `}`, - ``, - ); - for (const line of sp) out.push(line); - } - return out; + emitGenScaffolding(): string[] { + return emitGenScaffolding(this); } // ── plumbing (frame and scope ownership) ────── @@ -5107,7 +2238,7 @@ export class LlEmitter { } } - private releaseScope(scope: LlScopeEntry[]): void { + releaseScope(scope: LlScopeEntry[]): void { for (const e of scope) { const t = this.B.tmp(); this.B.line(`${t} = load ptr, ptr ${e.slot}`); @@ -5136,7 +2267,7 @@ export class LlEmitter { * frames and entered scopes down to the given depths, innermost first — * everything whose normal fall-through releases the jump bypasses. * Release every scope crossed by the control-flow edge. */ - private releaseForJump(frameDepth: number, scopeDepth: number): void { + releaseForJump(frameDepth: number, scopeDepth: number): void { for (let i = this.frames.length - 1; i >= frameDepth; i--) this.releaseTemps(this.frames[i]!); for (let i = this.scopes.length - 1; i >= scopeDepth; i--) this.releaseScope(this.scopes[i]!); } @@ -5144,7 +2275,7 @@ export class LlEmitter { /** Run the finally regions an abrupt loop/block jump crosses, then * release to the already-resolved target. This is the return path's * completion walk without a parked result value. */ - private emitFinallysForJump(finallyDepth: number, frameDepth: number, scopeDepth: number): void { + emitFinallysForJump(finallyDepth: number, frameDepth: number, scopeDepth: number): void { if (this.finallyStack.length <= finallyDepth) { this.releaseForJump(frameDepth, scopeDepth); return; @@ -5178,7 +2309,7 @@ export class LlEmitter { * result of the function's own return type: with nothing to release, the * unwind returns it as the dummy, so a call in tail position keeps a single * return and stays eligible for tail-call and recursion elimination. */ - private emitUnwind(passThrough?: string): void { + emitUnwind(passThrough?: string): void { if (this.mod.workers && this.tryStack.length > 0) { const stop = this.B.newLabel("context.stop"); const ordinary = this.B.newLabel("context.throw"); @@ -5414,7 +2545,7 @@ export class LlEmitter { /** A recursive function's entry check: below the running stack's guard, * throw Node's RangeError and unwind like any other pending exception. */ - private emitStackCheck(): void { + emitStackCheck(): void { const B = this.B; this.declare(`declare ptr @llvm.stacksave.p0()`); this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); @@ -5501,7 +2632,7 @@ export class LlEmitter { } } - private workerLoopBudget( + workerLoopBudget( loop: Extract, ): string | null { if (!this.mod.workers) return null; @@ -5513,128 +2644,16 @@ export class LlEmitter { this.B.line(`store i32 0, ptr ${slot}`); return slot; } - - /** Emit a loop's condition, body and update through `emitCopy`, twice - * when speculative-integers.ts plans a fast integer copy. Both copies - * share the loop's setup and exit; the ordinary copy comes first so the - * fast copy is only emitted when every resume point exists. */ - private emitLoopCopies( + emitLoopCopies( loop: Extract, allowed: boolean, exit: string, emitCopy: (exit: string) => void, ): void { - const plan = - allowed && - this.speculativeLoopsEnabled && - this.speculation === null && - this.speculationResume === null - ? planSpeculativeLoop( - loop, - (id) => this.speculativeLocal(id), - (id) => this.integerLoopBindings.has(id) || this.int32Locals.has(id), - (e) => { - const range = this.integerRanges.get(e); - return ( - range !== undefined && - range !== null && - range.min >= -Number.MAX_SAFE_INTEGER && - range.max <= Number.MAX_SAFE_INTEGER - ); - }, - (id) => this.speculativeLocal(id) || this.speculativeInvariantGlobal(id), - ) - : null; - if (!plan) { - emitCopy(exit); - return; - } - const B = this.B; - const entries = new Map(); - let ok = "true"; - for (const [id, live] of plan.locals) { - if (!live) continue; - const value = B.tmp(); - B.line(`${value} = load double, ptr ${this.binding(id).slot}`); - const [integer, exact] = emitSafeIntegerCheck(this, value); - entries.set(id, integer); - if (ok === "true") ok = exact; - else { - const both = B.tmp(); - B.line(`${both} = and i1 ${ok}, ${exact}`); - ok = both; - } - } - const fast = B.newLabel("spec.fast"), - slow = B.newLabel("spec.slow"); - B.condBr(ok, fast, slow); - const resume = new Map(); - for (const s of plan.writes) resume.set(s, { label: B.newLabel("spec.resume"), placed: false }); - B.startBlock(slow); - this.speculationResume = resume; - try { - emitCopy(exit); - } finally { - this.speculationResume = null; - } - B.startBlock(fast); - if (![...resume.values()].every((r) => r.placed)) { - B.br(slow); - return; - } - for (const id of plan.locals.keys()) { - const shadow = B.slot(); - B.entryAllocas.push( - `${shadow} = alloca i64 ; speculative integer ${this.currentLocals.get(id)?.name ?? id}`, - ); - const entry = entries.get(id); - if (entry !== undefined) { - // The same double, now visibly an integer conversion: the double - // phis of the fast copy fold into integer phis. - const exact = B.tmp(); - B.line(`store i64 ${entry}, ptr ${shadow}`); - B.line(`${exact} = sitofp i64 ${entry} to double`); - B.line(`store double ${exact}, ptr ${this.binding(id).slot}`); - } else B.line(`store i64 0, ptr ${shadow}`); - this.integerLoopBindings.set(id, { - slot: shadow, - type: "i64", - signed: true, - range: SPECULATIVE_RANGE, - }); - } - // A global's store stays in the loop (calls may read it). When nothing - // in the loop can observe it, its double is written once on leaving the - // copy instead, so the loop keeps it in a register as the double copy - // would. - const deferred = new Set(); - // Worker loops also leave through their cancellation poll. - if (plan.observerFree && !this.mod.workers) - for (const s of plan.writes) { - const id = speculativeWriteTarget(s)!.localId; - if (!this.currentLocals.has(id)) deferred.add(id); - } - this.speculation = { - writes: plan.writes, - resume: new Map([...resume].map(([s, r]) => [s, r.label])), - deferred, - }; - try { - if (deferred.size === 0) emitCopy(exit); - else { - const leave = B.newLabel("spec.leave"); - emitCopy(leave); - B.startBlock(leave); - this.writeBackSpeculativeGlobals(deferred); - B.br(exit); - } - } finally { - this.speculation = null; - for (const id of plan.locals.keys()) this.integerLoopBindings.delete(id); - } + return emitLoopCopies(this, loop, allowed, exit, emitCopy); } - private writeBackSpeculativeGlobals(deferred: Set): void { + writeBackSpeculativeGlobals(deferred: Set): void { const B = this.B; for (const id of deferred) { const integer = B.tmp(), @@ -5647,7 +2666,7 @@ export class LlEmitter { /** A number local whose only storage is its own double slot, so an i64 * shadow beside it can stand for it within one loop. */ - private speculativeLocal(id: string): boolean { + speculativeLocal(id: string): boolean { const local = this.currentLocals.get(id); if (!local) return this.speculativeGlobal(id); if (local.type.kind !== "f64" || local.boxed || local.tdz) return false; @@ -5679,7 +2698,7 @@ export class LlEmitter { /** A module-level number a loop of any function only reads, written by * nothing but top-level code: an init function never runs during a loop * of another function, so the entry value stays its value. */ - private speculativeInvariantGlobal(id: string): boolean { + speculativeInvariantGlobal(id: string): boolean { const writers = this.numberGlobalWriters(id); return writers !== null && [...writers].every((name) => name.startsWith("%init.")); } @@ -5713,65 +2732,8 @@ export class LlEmitter { } return this.globalWriters.get(id) ?? new Set(); } - - /** A speculated write in the fast copy: compute the value as a safe - * integer, store it to the shadow and its exact double to the local. - * Any failed check re-executes the statement in the ordinary copy. */ private emitSpeculativeWrite(s: IrStmt, resume: string): void { - const B = this.B; - const target = speculativeWriteTarget(s)!; - const binding = this.integerLoopBindings.get(target.localId)!; - const failed = B.newLabel("spec.bail"); - let bails = false; - this.declare("declare i1 @llvm.expect.i1(i1, i1)"); - const bail = (condition: string): void => { - if (condition === "false") return; - bails = true; - const expected = B.tmp(); - B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${condition}, i1 false)`); - const next = B.newLabel("spec.ok"); - B.condBr(expected, failed, next); - B.startBlock(next); - }; - // Heap reads come first, before any check, and release their temporaries - // at once: a bailing path then owns nothing, and reads have no effect - // whose reordering before the arithmetic could be observed. - const reads = new Map(); - if (target.value !== null) { - this.frames.push([]); - for (const read of speculativeReads(target.value)) reads.set(read, this.emitExpr(read)); - this.releaseTemps(this.frames.pop()!); - } - this.frames.push([]); - let result: string; - if (target.value === null) { - const old = B.tmp(); - result = B.tmp(); - B.line(`${old} = load i64, ptr ${binding.slot}`); - B.line(`${result} = ${target.op === "+" ? "add" : "sub"} nsw i64 ${old}, 1`); - const biased = B.tmp(), - outside = B.tmp(); - B.line(`${biased} = add i64 ${result}, ${Number.MAX_SAFE_INTEGER}`); - B.line(`${outside} = icmp ugt i64 ${biased}, ${2 * Number.MAX_SAFE_INTEGER}`); - bail(outside); - } else result = emitSpeculativeInteger(this, target.value, bail, reads); - // Speculable values read only numbers past this point. - if (this.frames.pop()!.length > 0) - throw new InternalCompilerError("llvm emitter bug: speculative write owns a temporary"); - B.line(`store i64 ${result}, ptr ${binding.slot}`); - const deferred = this.speculation!.deferred; - if (!deferred.has(target.localId)) { - const number = B.tmp(); - B.line(`${number} = sitofp i64 ${result} to double`); - B.line(`store double ${number}, ptr ${this.binding(target.localId).slot}`); - } - if (!bails) return; - const done = B.newLabel("spec.done"); - B.br(done); - B.startBlock(failed); - this.writeBackSpeculativeGlobals(deferred); - B.br(resume); - B.startBlock(done); + return emitSpeculativeWrite(this, s, resume); } /** The amortized cancellation poll, emitted on the loop's back edge (the @@ -5779,7 +2741,7 @@ export class LlEmitter { * exit ahead of the body; LLVM then cannot rotate the loop, so loads that * the body repeats on every iteration (a typed array's length, for one) * stay in the loop. */ - private emitWorkerLoopCheck(slot: string | null): void { + emitWorkerLoopCheck(slot: string | null): void { if (slot === null) return; const B = this.B; const budget = B.tmp(), @@ -6409,7 +3371,7 @@ export class LlEmitter { }; } - private emitDebugLocal(local: IrLocal, slot: string, arg = 0, captured = false): void { + emitDebugLocal(local: IrLocal, slot: string, arg = 0, captured = false): void { const debug = this.debug?.local(local, this.debugScope, arg, captured); if (!debug) return; this.declare("declare void @llvm.dbg.declare(metadata, metadata, metadata)"); @@ -6426,7 +3388,7 @@ export class LlEmitter { } /** The payload slot of a capture box (ScrBox.slot, field 5). */ - private boxSlot(box: string): string { + boxSlot(box: string): string { const slot = this.B.tmp(); this.B.line(`${slot} = getelementptr inbounds %ScrBox, ptr ${box}, i32 0, i32 5`); return slot; @@ -6505,7 +3467,7 @@ export class LlEmitter { /** The initializing write of a scalar TDZ box: mint the one-element * array cell holding the value and move it into the ARR-kind box * (set_ref releases nothing — the slot was the empty sentinel). */ - private tdzScalarInit(box: string, t: IrType, value: string): void { + tdzScalarInit(box: string, t: IrType, value: string): void { const B = this.B; const acc = boxAccess(t); const cell = B.tmp(); @@ -6521,7 +3483,7 @@ export class LlEmitter { * read back as the temporal dead zone; such boxes start at the module's * immortal NULLABLE_ABSENT sentinel instead, which no value of the * binding can hold and every RC entry point and the collector skip. */ - private tdzEmptySentinel(t: IrType): string | null { + tdzEmptySentinel(t: IrType): string | null { if (!this.nullableUnions.of(t)) return null; this.needsNullableAbsent = true; this.immortalValues.add(NULLABLE_ABSENT); @@ -6805,75 +3767,13 @@ export class LlEmitter { ); this.B.startBlock(resume); } - - /** Move a clean async return value into the current fiber's promise. */ - private emitWasiFulfill(v: LlValue | null): void { - const coro = this.currentCoro; - if (coro === null) - throw new InternalCompilerError("llvm emitter bug: wasm fulfillment outside async body"); - const ret = this.currentReturnType; - if (coro.kind === "generator") { - this.declare(`declare ptr @scr_gen_of_fiber(ptr)`); - const gen = this.B.tmp(); - this.B.line(`${gen} = call ptr @scr_gen_of_fiber(ptr ${coro.self})`); - switch (ret.kind) { - case "void": - return; - case "f64": - case "date": - this.declare(`declare void @scr_gen_out_f64(ptr, double)`); - this.B.line(`call void @scr_gen_out_f64(ptr ${gen}, double ${v!.name})`); - return; - case "bool": - this.declare(`declare void @scr_gen_out_bool(ptr, i1 zeroext)`); - this.B.line(`call void @scr_gen_out_bool(ptr ${gen}, i1 ${v!.name})`); - return; - default: - this.declare(`declare void @scr_gen_out_ref(ptr, ptr, ptr)`); - this.B.line( - `call void @scr_gen_out_ref(ptr ${gen}, ptr ${v!.name}, ptr ${vAdapters(this.shapeHost, ret).release})`, - ); - return; - } - } - this.declare(`declare ptr @scr_fiber_promise(ptr)`); - const pr = this.B.tmp(); - this.B.line(`${pr} = call ptr @scr_fiber_promise(ptr ${coro.self})`); - switch (ret.kind) { - case "void": - this.declare(`declare void @scr_promise_fulfill_void(ptr)`); - this.B.line(`call void @scr_promise_fulfill_void(ptr ${pr})`); - break; - case "f64": - case "date": - this.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`); - this.B.line(`call void @scr_promise_fulfill_f64(ptr ${pr}, double ${v!.name})`); - break; - case "bool": - this.declare(`declare void @scr_promise_fulfill_bool(ptr, i1 zeroext)`); - this.B.line(`call void @scr_promise_fulfill_bool(ptr ${pr}, i1 ${v!.name})`); - break; - case "string": - this.declare(`declare void @scr_promise_fulfill_str(ptr, ptr)`); - this.B.line(`call void @scr_promise_fulfill_str(ptr ${pr}, ptr ${v!.name}) ; moves in`); - break; - case "dyn": - this.declare(`declare void @scr_promise_resolve_dyn(ptr, ptr)`); - this.B.line(`call void @scr_promise_resolve_dyn(ptr ${pr}, ptr ${v!.name}) ; moves in`); - break; - default: { - const rc = vAdapters(this.shapeHost, ret); - this.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); - this.B.line( - `call void @scr_promise_fulfill_ref(ptr ${pr}, ptr ${v!.name}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(this.shapeHost, ret)})`, - ); - } - } + emitWasiFulfill(v: LlValue | null): void { + return emitWasiFulfill(this, v); } /** Number locals declared as a copy of a capture or of a lazily boxed * parameter, at the top of the body. */ - private doubleCopies(fn: IrFunction): string[] { + doubleCopies(fn: IrFunction): string[] { const copies: string[] = []; for (const s of fn.body) { if (s.kind !== "varDecl") break; @@ -6883,596 +3783,14 @@ export class LlEmitter { } return copies; } - - private emitFunction(fn: IrFunction): string { - const B = new BlockBuilder(); - this.B = B; - this.currentFunctionName = fn.name; - // Fiber switches restore the active exception cell before control - // returns to a synchronous frame (emitPendingCheck). - B.excCellInvariant = fn.async !== true && fn.generator === undefined; - this.debugScope = this.debug?.function(fn) ?? null; - B.debugLocation = this.debug?.location(fn.loc, this.debugScope) ?? null; - this.frames = []; - this.scopes = []; - this.unwindCleanups.clear(); - this.jumpTargets = []; - this.currentLocals = new Map(fn.locals.map((l) => [l.id, l])); - this.lazyCaptureBoxes.clear(); - this.lazyStackBoxes.clear(); - // An unchanged captured parameter keeps its value in a plain slot; the - // binding is boxed only for closures (captureBox). - const lazyParameters = this.lazyCaptures.parameters(fn); - for (const id of lazyParameters) { - const { boxed: _boxed, ...plain } = this.currentLocals.get(id)!; - this.currentLocals.set(id, plain); - this.lazyCaptureBoxes.set(id, ""); - } - this.currentConstantCallbacks = - this.debug === null ? (this.constantCallbacks.get(fn.name) ?? new Map()) : new Map(); - // Preserve concrete source bindings for debugger inspection. Suspended - // functions keep the established array lifetime across continuations. - this.streamingSplitsEnabled = this.debug === null && !fn.async && !fn.generator; - this.stackCallbacks.reset(this.streamingSplitsEnabled); - this.stackCaptures.reset(fn, this.streamingSplitsEnabled, lazyParameters); - this.privateSplitLocals = this.streamingSplitsEnabled ? findPrivateSplitLocals(fn) : new Map(); - this.storedSplits.clear(); - this.splitSpans.clear(); - this.stringSlices.clear(); - const initializerBindings = this.initializerBindings.get(fn.name) ?? []; - const numericFn = withInitializerBindings(fn, initializerBindings); - this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); - this.unaliasedArrays = findUnaliasedLocalArrays(fn); - this.borrowedParameters.clear(); - this.referenceWrites?.enter(fn); - this.reboundOwnerSlots.clear(); - this.projectedParameters.clear(); - const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name); - if (projectedParameterIndexes) - for (const index of projectedParameterIndexes) - this.projectedParameters.add(fn.params[index]!.localId); - this.stableCallBindings = this.callLifetimes.bindings.get(fn.name) ?? new Set(); - const borrowedParameterIndexes = this.callLifetimes.borrowed.get(fn.name); - if (borrowedParameterIndexes) { - // Rebound walk parameters borrow too, but only unchanged ones may - // serve as stable owners for call arguments and aliases. - for (const index of borrowedParameterIndexes) - if ( - !this.walkBorrowsByFunction.get(fn.name)?.has(fn.params[index]!.localId) && - !this.reboundByFunction.get(fn.name)?.has(fn.params[index]!.localId) - ) - this.borrowedParameters.add(fn.params[index]!.localId); - } - // A generator frame owns its unchanged parameters across suspensions. - const generatorParameters = this.callLifetimes.generatorParameters.get(fn.name); - if (generatorParameters !== undefined) - for (const id of generatorParameters) this.borrowedParameters.add(id); - this.captureIds = new Set( - [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), - ); - this.stableCaptureIds = - fn.captures !== undefined ? this.lazyCaptures.stableCaptures(fn.name) : new Set(); - this.integerLoopBindings.clear(); - this.countedLoopsEnabled = this.debug === null && !fn.async && !fn.generator; - this.speculativeLoopsEnabled = this.countedLoopsEnabled; - this.speculativeFunction = fn.name; - this.speculation = null; - this.speculationResume = null; - this.finalFieldBorrows = - !fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0; - this.byteWindowsEnabled = this.countedLoopsEnabled; - this.denseLoopsEnabled = this.countedLoopsEnabled; - this.denseLoop = null; - this.denseLoopEntries = - this.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array") - ? findInitializedByteLoopBindings(fn) - : new Map(); - this.byteWindowEntries = - this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes") - ? findInitializedByteLoopBindings(fn) - : new Map(); - this.integerViews.clear(); - this.fieldPointerTags.clear(); - this.integerArrayBindings.clear(); - this.loopArrayBorrows = - this.debug === null - ? findLoopArrayBorrows( - fn, - this.callLifetimes, - this.referenceEffects, - this.optionalArrayReads, - this.referenceWrites, - this.fnByName, - ) - : new Set(); - this.localArrayReads = findLocalArrayReads( - fn, - this.fnByName, - this.unionsById, - this.referenceEffects.functions, - this.callLifetimes, - this.optionalArrayReads, - this.loopArrayBorrows, - this.debug === null ? this.referenceEffects : undefined, - ); - this.callArrayReads = findCallArrayReads( - fn, - this.optionalArrayReads, - this.referenceEffects.functions, - this.callLifetimes, - this.debug === null ? this.referenceEffects : undefined, - ); - this.scalarStringSlices = - this.debug === null ? findScalarStringSlices(fn, this.callLifetimes) : new Map(); - this.scratchStringSlices = - this.debug === null - ? findScratchStringSlices( - fn, - this.callLifetimes, - this.scalarStringSlices, - this.lentStringParameters, - ) - : new Set(); - this.mapReadLifetimes = findMapReadLifetimes(fn, this.boxedUnionsById, this.callLifetimes); - this.readNumberMapWrites = findReadNumberMapWrites(fn); - this.mapEntryFusion = this.debug === null ? findMapEntryFusions(fn) : null; - this.localStackUnions = findLocalStackUnions(fn, this.callLifetimes, this.boxedUnionsById); - this.localStackObjects = this.stackObjects - ? findLocalStackObjects(this, fn, this.callLifetimes) - : new Map(); - this.reassignedStackObjects = this.stackObjects - ? findReassignedStackObjects(this, fn, this.callLifetimes) - : new Set(); - this.stackObjectSlots = new Map(); - this.stackObjectFieldPtrs = new Map(); - this.localUnionStorageProofs = findLocalUnionStorage(fn, this.callLifetimes, this.unionSources); - this.localUnionStorage = new Map(); - this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); - const slotFacts = this.int32Slots.facts(fn.name); - this.integerRanges = analyzeIntegerRanges(numericFn, slotFacts); - this.int32Elements = slotFacts.elements ?? null; - this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); - // Debug builds keep every number local a double for the debugger's - // variable view. Lazily boxed captured parameters keep their double, - // which the box is created from, and so do plain copies of a capture or - // such a parameter (copyStableCaptures): the source already holds the - // double, which an i32 copy would convert and widen again on every call. - // Initializer bindings proven int32 move into an i32 local of their - // initializer: no other code observes the global, so the loop-carried - // value never round-trips through the double slot. - const initializerLocals = initializerLocalCandidates(initializerBindings); - const int32Locals = - this.debug === null - ? findInt32Locals( - withInitializerLocals(fn, initializerLocals), - this.integerRanges, - this.int32Slots.facts(fn.name), - new Set([...this.lazyCaptureBoxes.keys(), ...this.doubleCopies(fn)]), - ) - : null; - this.int32Locals = int32Locals?.locals ?? new Map(); - this.int32LocalWrites = int32Locals?.values ?? new Set(); - const localizedGlobals = new Set(); - for (const global of initializerLocals) { - if (!this.int32Locals.has(global.id)) continue; - localizedGlobals.add(global.id); - this.currentLocals.set(global.id, { - id: global.id, - name: global.name, - type: global.type, - mutable: global.mutable, - }); - } - this.chainSlots.clear(); - this.finallyStack = []; - this.tryStack = []; - this.loopDepth = 0; - this.currentReturnType = fn.returnType; - const int32Abi = this.int32Abis.get(fn.name); - this.currentInt32Result = int32Abi?.result === true; - this.currentUnionResult = int32Abi?.union === true; - this.currentGenerator = fn.generator ?? null; - this.currentAsyncGenerator = fn.async === true && fn.generator !== undefined; - this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); - this.lastUseMoves.clear(); - this.varRefCounts = null; - this.currentFunctionBody = fn.body; - this.inConstructor = this.constructorFunctions.has(fn.name); - this.currentCoro = null; - this.logArgSlots = 0; - - if (this.coroLowered(fn)) { - this.usesCoroutines = true; - this.declare(`declare token @llvm.coro.id(i32, ptr, ptr, ptr)`); - this.declare(`declare ${this.sizeType} @llvm.coro.size.${this.sizeType}()`); - this.declare(`declare ptr @llvm.coro.begin(token, ptr)`); - this.declare(`declare token @llvm.coro.save(ptr)`); - this.declare(`declare i8 @llvm.coro.suspend(token, i1)`); - this.declare(`declare ptr @llvm.coro.free(token, ptr)`); - this.declare(`declare void @llvm.coro.end(ptr, i1, token)`); - // Native frames come from the runtime's small-block allocator. - const frameAlloc = this.wasi ? "malloc" : "scr_rt_calloc"; - this.declare(`declare ptr @${frameAlloc}(${this.sizeType})`); - this.declare(`declare void @${this.wasi ? "free" : "scr_rt_free"}(ptr)`); - const started = this.wasi ? "scr_wasi_coro_started" : "scr_coro_started"; - this.declare(`declare void @${started}(ptr)`); - this.declare(`declare ptr @scr_fiber_self()`); - const id = B.tmp(); - const size = B.tmp(); - const mem = B.tmp(); - const handle = B.tmp(); - const self = B.tmp(); - B.line(`${id} = call token @llvm.coro.id(i32 0, ptr null, ptr null, ptr null)`); - B.line(`${size} = call ${this.sizeType} @llvm.coro.size.${this.sizeType}()`); - B.line(`${mem} = call ptr @${frameAlloc}(${this.sizeType} ${size})`); - B.line(`${handle} = call ptr @llvm.coro.begin(token ${id}, ptr ${mem})`); - B.line(`call void @${started}(ptr ${handle})`); - B.line(`${self} = call ptr @scr_fiber_self()`); - // A native synchronous body owns its generator for the whole run (the - // ScrGen owns the frame), so its OUT slot address is fixed. - let out: string | null = null; - const yieldKind = fn.generator?.yieldT.kind; - if ( - !this.wasi && - fn.async !== true && - (yieldKind === "f64" || yieldKind === "date" || yieldKind === "bool") - ) { - this.declare(`declare ptr @scr_gen_of_fiber(ptr)`); - const gen = B.tmp(); - B.line(`${gen} = call ptr @scr_gen_of_fiber(ptr ${self})`); - out = this.genOutSlot(gen); - } - this.currentCoro = { - kind: fn.generator !== undefined ? "generator" : "async", - id, - handle, - self, - finalLabel: B.newLabel("coro.final"), - cleanupLabel: B.newLabel("coro.cleanup"), - suspendLabel: B.newLabel("coro.suspend"), - out, - }; - } - - const paramIds = new Set(fn.params.map((p) => p.localId)); - if (this.debug === null) { - for (const id of findIntegerViews(numericFn)) { - // An i32 slot is its own view. - if (this.int32Locals.has(id)) continue; - const slot = B.slot(); - B.entryAllocas.push( - `${slot} = alloca i32 ; integer view ${this.numericLocals.get(id)!.name}`, - ); - this.integerViews.set(id, slot); - } - } - for (const global of initializerBindings) { - if (!this.integerViews.has(global.id)) continue; - const value = B.tmp(); - B.line(`${value} = load double, ptr @${mangleGlobal(global.id)}`); - this.storeIntegerView(global.id, { name: value, type: global.type }); - } - // A number global only its guarded, synchronous initializer reads or - // writes (findInitializerBindings) is invisible to every other function, - // and reentry returns before reaching it, so a local slot can stand in - // for it: LLVM keeps it in a register across calls and loops that would - // otherwise force a store and reload per iteration. The global's - // storage is never read again, so nothing writes the slot back. Bindings - // proven int32 already live in an i32 local (localizedGlobals above); - // this covers the remaining doubles. - this.privateGlobalSlots.clear(); - if (this.debug === null && this.currentCoro === null && !fn.async && !fn.generator) { - const conditional = initializerLocals.length ? conditionalLoopWrites(fn.body) : null; - for (const global of initializerLocals) { - if (localizedGlobals.has(global.id)) continue; - // A conditional update in a loop stays a memory update (see - // conditionalLoopWrites). - if (conditional?.has(global.id)) continue; - const slot = B.slot(); - const value = B.tmp(); - B.entryAllocas.push(`${slot} = alloca double ; module binding ${global.name}`); - B.line(`${value} = load double, ptr @${mangleGlobal(global.id)}`); - B.line(`store double ${value}, ptr ${slot}`); - this.privateGlobalSlots.set(global.id, slot); - } - } - for (const local of this.currentLocals.values()) { - // Boxed locals' slots hold their capture BOX (a ptr); captured - // (env-borrowed) locals bind the incoming box below. A caught-typed - // local is a catch binding: its slot holds the ScrCaught snapshot - // box the catch prologue takes (scr_exc_take). - const slotTy = - local.boxed || this.captureIds.has(local.id) || local.type.kind === "caught" - ? "ptr" - : this.int32Locals.has(local.id) - ? "i32" - : this.llType(local.type); - B.entryAllocas.push(`%${mangleLocal(local.id)} = alloca ${slotTy} ; ${local.name}`); - this.emitDebugLocal( - local, - `%${mangleLocal(local.id)}`, - fn.params.findIndex((p) => p.localId === local.id) + 1, - this.captureIds.has(local.id), - ); - // A localized initializer binding starts at its global's zero value. - if (localizedGlobals.has(local.id)) B.line(`store i32 0, ptr %${mangleLocal(local.id)}`); - // Refcounted/boxed locals start NULL (the C prologue's `= NULL`): - // scope-exit releases run whether or not an assign ever did. - if (paramIds.has(local.id) || this.captureIds.has(local.id)) continue; - if (local.boxed || isRefCounted(local.type)) { - B.line(`store ptr null, ptr %${mangleLocal(local.id)}`); - } - } - // A body holding thousands of capture boxes compiles unoptimized, its - // box pointers kept in their slots (see boxSlotCount). - const unoptimized = this.currentCoro === null && boxSlotCount(fn) >= UNOPTIMIZED_BOX_SLOTS; - if (unoptimized) { - const slots = new Set(); - for (const local of this.currentLocals.values()) - if (local.boxed || this.captureIds.has(local.id)) slots.add(`%${mangleLocal(local.id)}`); - B.volatileSlots = slots; - } - // Captured bindings come in through the environment — borrowed for the - // whole call (the closure owns them): bound here, never released here. - (fn.captures ?? []).forEach((c, i) => { - const caps = B.tmp(); - const p = B.tmp(); - const box = B.tmp(); - B.line(`${caps} = getelementptr inbounds %ScrClosure, ptr %sc_env, i64 1 ; caps`); - B.line(`${p} = getelementptr inbounds ptr, ptr ${caps}, ${this.sizeType} ${i} ; caps[${i}]`); - B.line(`${box} = load ptr, ptr ${p}`); - B.line(`store ptr ${box}, ptr %${mangleLocal(c.localId)} ; captured ${c.name}`); - }); - (fn.classCaptures ?? []).forEach((c) => { - const self = fn.params[0]!; - if (self.type.kind !== "object") - throw new InternalCompilerError("class captures require an instance receiver"); - const classSlot = B.tmp(); - const classValue = B.tmp(); - const caps = B.tmp(); - const slot = B.tmp(); - const box = B.tmp(); - B.line( - `${classSlot} = getelementptr inbounds %${mangleClassStruct(self.type.className)}, ptr %p_${mangleLocal(self.localId)}, i64 0, i32 ${classEnvironmentIndex(this.classMeta.get(self.type.className)!)}`, - ); - B.line(`${classValue} = load ptr, ptr ${classSlot}`); - B.line(`${caps} = getelementptr inbounds %ScrClassObj, ptr ${classValue}, i64 1`); - B.line(`${slot} = getelementptr inbounds ptr, ptr ${caps}, ${this.sizeType} ${c.slot}`); - B.line(`${box} = load ptr, ptr ${slot}`); - B.line(`store ptr ${box}, ptr %${mangleLocal(c.localId)} ; class capture ${c.name}`); - }); - // Params spill into their slots; the function scope owns refcounted - // params (callees own their params — callers passed +1). Boxed params - // allocate the shared binding and move the raw value in. - const fnScope: LlScopeEntry[] = []; - for (const id of this.lazyCaptureBoxes.keys()) { - const cache = B.slot(); - B.entryAllocas.push(`${cache} = alloca ptr ; lazy box ${this.currentLocals.get(id)!.name}`); - B.line(`store ptr null, ptr ${cache}`); - fnScope.push({ - slot: cache, - type: this.currentLocals.get(id)!.type, - boxed: true, - lazy: true, - }); - this.lazyCaptureBoxes.set(id, cache); - // Environments that finish during the call can share an immortal - // frame box. Its payload owner is released with the function scope. - const stack = this.stackCaptures.emit(this, id, this.currentLocals.get(id)!.type); - if (stack) { - this.lazyStackBoxes.set(id, stack.box); - if (stack.owner) fnScope.push(stack.owner); - } - } - const borrowed = this.callLifetimes.borrowed.get(fn.name); - for (const [index, p] of fn.params.entries()) { - const local = this.currentLocals.get(p.localId)!; - const slot = `%${mangleLocal(p.localId)}`; - if (local.boxed) { - const box = B.tmp(); - B.line(`${box} = ${boxNewCall(this.shapeHost, p.type)} ; ${p.name} (boxed param)`); - this.boxSet(box, p.type, `%p_${mangleLocal(p.localId)}`); - B.line(`store ptr ${box}, ptr ${slot}`); - fnScope.push({ slot, type: p.type, boxed: true }); - continue; - } - const incoming: LlValue = { name: `%p_${mangleLocal(p.localId)}`, type: p.type }; - if (int32Abi?.params[index]) { - const bits = `%p_${mangleLocal(p.localId)}${INT32_ABI_SUFFIX}`; - incoming.uint32 = bits; - incoming.integer = { name: bits, type: "i32", signed: true, range: INT32_RANGE }; - } - if (this.storeInt32Local(p.localId, incoming)) continue; - B.line(`store ${this.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); - this.storeIntegerView(p.localId, incoming); - if (isRefCounted(p.type) && !borrowed?.has(index)) { - fnScope.push({ slot, type: p.type }); - this.noteParameterMove(fn, p.localId, slot); - } - if (this.reboundByFunction.get(fn.name)?.has(p.localId) && borrowed?.has(index)) { - // The caller's borrow stays in the parameter slot; values the body - // assigns are owned here until rebinding or exit releases them. - const owner = `${slot}.owner`; - B.entryAllocas.push(`${owner} = alloca ptr`); - B.line(`store ptr null, ptr ${owner}`); - fnScope.push({ slot: owner, type: p.type }); - this.reboundOwnerSlots.set(p.localId, owner); - } - } - this.scopes.push(fnScope); - // Stackful native fibers and native coroutine frames retain these frames - // while suspended. The active exception context owns the chain, so - // concurrent fibers isolate it. - if ( - this.stackTraces && - !this.wasi && - fn.sourceName !== undefined && - !this.unframedFunctions.has(fn.name) - ) { - B.entryAllocas.push(`%source_frame = alloca { ptr, ptr }`); - const frame = this.cstr(` at ${fn.sourceName} (${fn.loc.file})`); - if ( - B.excCellInvariant && - this.mod.lib === undefined && - this.mod.workers !== true && - this.sizeType === "i64" - ) { - // Executables link the active-cell pointer as a plain global, and a - // synchronous body sees one cell from entry to every return: push - // and pop the frame inline (ScrExcCell.stack is at byte 56 on - // 64-bit targets) instead of calling the runtime twice per call. - this.declare(`@scr_exc_active = external global ptr`); - B.line(`%source_frame.cell = load ptr, ptr @scr_exc_active`); - B.line(`%source_frame.chain = getelementptr inbounds i8, ptr %source_frame.cell, i64 56`); - B.line(`%source_frame.previous = load ptr, ptr %source_frame.chain`); - B.line(`store ptr %source_frame.previous, ptr %source_frame`); - B.line(`%source_frame.text = getelementptr inbounds i8, ptr %source_frame, i64 8`); - B.line(`store ptr ${frame}, ptr %source_frame.text`); - B.line(`store ptr %source_frame, ptr %source_frame.chain`); - B.returnEpilogue = `store ptr %source_frame.previous, ptr %source_frame.chain`; - } else { - this.declare(`declare void @scr_stack_enter(ptr, ptr)`); - this.declare(`declare void @scr_stack_leave(ptr)`); - B.line(`call void @scr_stack_enter(ptr %source_frame, ptr ${frame})`); - B.returnEpilogue = `call void @scr_stack_leave(ptr %source_frame)`; - } - } - if (this.stackChecks?.has(fn.name)) this.emitStackCheck(); - if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true); - this.emitStmts(fn.body, true); - // Implicit exit of a void function: release the function scope unless - // the body already terminated its final block (return, or a throw - // whose unwind released everything down to depth 0). - if (fn.returnType.kind === "void" && !B.isTerminated()) { - this.releaseScope(this.scopes[0]!); - // Completing by falling off the end has nothing to await. - if (this.currentAsyncGenerator) this.emitAsyncGenSettled(); - if (this.currentCoro !== null) { - this.emitWasiFulfill(null); - B.terminate(`br label %${this.currentCoro.finalLabel}`); - } else { - B.terminate("ret void"); - } - } - this.scopes.pop(); - - const coro = this.currentCoro; - if (coro !== null) { - B.startBlock(coro.finalLabel); - // Every exit of a coroutine body joins here: its source frame (part - // of the coroutine frame) leaves the generator's own chain. - const epilogue = B.returnEpilogue; - B.returnEpilogue = null; - if (epilogue !== null) B.line(epilogue); - if (fn.captures !== undefined) { - this.declare(`declare void @scr_closure_release(ptr)`); - B.line(`call void @scr_closure_release(ptr %sc_env)`); - } - if (coro.kind === "generator") { - const finish = this.wasi ? "scr_wasi_gen_finish" : "scr_gen_coro_finish"; - this.declare(`declare void @${finish}(ptr)`); - B.line(`call void @${finish}(ptr ${coro.self})`); - } else { - const finish = this.wasi ? "scr_wasi_async_finish" : "scr_coro_async_finish"; - this.declare(`declare void @${finish}(ptr)`); - B.line(`call void @${finish}(ptr ${coro.self})`); - } - const finalState = B.tmp(); - B.line(`${finalState} = call i8 @llvm.coro.suspend(token none, i1 true)`); - B.terminate( - `switch i8 ${finalState}, label %${coro.suspendLabel} [ i8 1, label %${coro.cleanupLabel} ]`, - ); - B.startBlock(coro.cleanupLabel); - const frame = B.tmp(); - B.line(`${frame} = call ptr @llvm.coro.free(token ${coro.id}, ptr ${coro.handle})`); - B.line(`call void @${this.wasi ? "free" : "scr_rt_free"}(ptr ${frame})`); - B.br(coro.suspendLabel); - B.startBlock(coro.suspendLabel); - B.line(`call void @llvm.coro.end(ptr ${coro.handle}, i1 false, token none)`); - const ret = this.llType(fn.returnType); - if (ret === "void") B.terminate(`ret void`); - else if (ret === "double") B.terminate(`ret double ${f64Lit(0)}`); - else if (ret === "i1") B.terminate(`ret i1 false`); - else B.terminate(`ret ptr null`); - } - - // Shared blocks have multiple source locations. Keep the location on - // each incoming exception check instead of assigning one to the cleanup. - B.debugLocation = null; - for (const cleanup of this.unwindCleanups.values()) { - B.startBlock(cleanup.label); - this.releaseScope(cleanup.entries); - B.terminate(cleanup.terminator); - } - - if (this.logArgSlots > 0) { - B.entryAllocas.push(`%logargs = alloca [${this.logArgSlots} x %ScrLogArg]`); - } - const params = fn.params.map((p, index) => - int32Abi?.params[index] - ? `i32 %p_${mangleLocal(p.localId)}${INT32_ABI_SUFFIX}` - : `${this.llType(p.type)} %p_${mangleLocal(p.localId)}`, - ); - // The i32 entry widens its i32 parameters once; the proof makes the - // double exact, so readers of the incoming number see the same value. - if (int32Abi) - fn.params.forEach((p, index) => { - if (!int32Abi.params[index]) return; - const name = `%p_${mangleLocal(p.localId)}`; - B.entryAllocas.push(`${name} = sitofp i32 ${name}${INT32_ABI_SUFFIX} to double`); - }); - // Lifted functions receive their closure first (the callValue ABI). - if (fn.captures !== undefined) params.unshift("ptr %sc_env"); - const ret = int32Abi?.result - ? "i32" - : int32Abi?.union - ? SCALAR_UNION_RESULT - : this.llType(fn.returnType); - let attrs = coro !== null ? "#1" : FN_ATTRS; - // The shared indexed-read helper (lowerSafeIndexRead) is a dense bounds - // test, a state test and a load behind cold runtime fallbacks, called - // from every `a[i]` of one element type. Without the hint LLVM keeps the - // hot instances out of line once they have many callers: every read pays - // a call, and an integer index converts to a double and back across it. - if (fn.name.startsWith("%arr.idxOr.")) attrs = `inlinehint ${attrs}`; - // Interface dispatch is a few key tests and direct calls ahead of the - // checked dynamic call. Keeping that fallback out of line leaves the - // tests small enough to inline at each call site. - if (fn.name.startsWith("%dyn.class.protocol.fallback.")) attrs = `noinline ${attrs}`; - else if (fn.name.startsWith("%dyn.class.protocol.")) attrs = `inlinehint ${attrs}`; - if (fn.name.startsWith(RESULT_ENTRY_PREFIX)) attrs = `inlinehint ${attrs}`; - if (unoptimized && attrs === FN_ATTRS) attrs = `noinline optnone ${attrs}`; - const debug = this.debugScope === null ? "" : ` !dbg ${this.debugScope}`; - const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name); - this.privateGlobalSlots.clear(); - let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`; - if (int32Abi) body += "\n" + this.emitInt32AbiWrapper(fn, symbol, int32Abi); - if (this.integerClones.members.has(fn.name)) { - body += "\n" + emitIntegerClone(fn, this, this.stackChecks?.has(fn.name) === true); - if (this.integerClones.dispatched.has(fn.name)) - body += - "\n" + - emitIntegerDispatcher( - fn, - this, - `${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}`, - int32Abi?.params ?? [], - int32Abi?.result === true, - ); - } - if (!borrowed) return body; - const slot = this.implSlotBorrowed(fn.name); - const virtual = - slot.size > 0 && slot.size !== borrowed.size - ? "\n" + this.emitOwnedCallAdapter(fn, borrowed, slot) - : ""; - return body + "\n" + this.emitOwnedCallAdapter(fn, borrowed) + virtual; + emitFunction(fn: IrFunction): string { + return emitFunction(this, fn); } /** The double-ABI entry of a function with an i32 entry: every caller * that is not a direct call (closures, dispatch, adapters, the runtime) * enters here. A pending exception passes through to its caller. */ - private emitInt32AbiWrapper(fn: IrFunction, symbol: string, abi: Int32Abi): string { + emitInt32AbiWrapper(fn: IrFunction, symbol: string, abi: Int32Abi): string { const lines: string[] = []; const params: string[] = []; const args: string[] = []; @@ -7632,7 +3950,7 @@ export class LlEmitter { /** With `kept`, the virtual adapter of a borrowing slot instead: the * slot's parameters stay borrowed, the body's other borrowed ones are * released here. */ - private emitOwnedCallAdapter( + emitOwnedCallAdapter( fn: IrFunction, borrowed: ReadonlySet, kept: ReadonlySet = NO_BORROWED, @@ -7672,86 +3990,13 @@ export class LlEmitter { kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name); return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`; } - - /** A local stable binding keeps its value alive while later arguments - * and the callee execute. A proven local union box cannot escape or be - * rebound during a call; its independent payload owner keeps it alive. - * Other writable bindings need an owned snapshot. */ canBorrowCallArgument(value: IrExpr): boolean { - if (value.kind === "strLit") return true; - // An exact capsule owns its instance while the capsule's binding holds it. - if (value.kind === "dynCheck" && value.exact) - return value.value.kind === "varRef" && this.canBorrowCallArgument(value.value); - // An inline union field has no box to lend. - if (this.inlineFieldGet(value)) return false; - // Class casts reinterpret the same pointer (prefix layouts). - if ( - (value.kind === "upcast" || value.kind === "downcast") && - value.value.type.kind === "object" - ) - return this.canBorrowCallArgument(value.value); - if (value.kind === "seqExpr") { - const guarded = guardedValue(value); - return guarded !== null && this.canBorrowCallArgument(guarded); - } - // A nullable-union wrap reinterprets its payload pointer; an immortal - // unit constant needs no owner at all. - const wrap = this.borrowableNullableWrap(value); - if (wrap === "unit") return true; - if (wrap === "ref" && value.kind === "unionWrap") - return this.canBorrowCallArgument(value.value); - // Checked casts (`x as C`, `x!` on a nullable) lower to a ternary whose - // failing arm always throws; the successful arm projects the same - // stable owner. The throwing arm's typed dummy owns nothing. - if (value.kind === "unionNarrow") return this.canBorrowCallArgument(value.value); - if (value.kind === "libCall") return value.fn === "error.nodeThrow"; - // A final field never changes outside constructors: while its object is - // borrowable, so is the field's value. Nullable-pointer fields have no - // box to lend and keep their owning path. - if ( - value.kind === "fieldGet" && - this.finalFieldBorrows && - isRefCounted(value.type) && - this.finalFields.isFinal(value.className, value.field) && - !this.nullableFieldGet(value) - ) - return this.canBorrowReceiver(value.obj) && this.canBorrowCallArgument(value.obj); - if (value.kind === "ternary") - return ( - this.canBorrowReceiver(value.then) && - this.canBorrowReceiver(value.else_) && - this.canBorrowCallArgument(value.then) && - this.canBorrowCallArgument(value.else_) - ); - // A construction-only field of a stable owner keeps its value until the - // owner dies: no later operand or callee can replace and release it. - if ((value.kind === "fieldGet" || value.kind === "recordGet") && this.immutableRead(value)) - return this.canBorrowCallArgument(value.obj); - // An optional chain's bound receiver stays unchanged until its body - // finishes (see emitReadReceiver). - if (value.kind === "chainRecv") return this.chainSlots.has(value.id); - if (value.kind !== "varRef") return false; - const binding = this.binding(value.localId); - // A module constant owns its value for the rest of the program. - if (binding.kind === "global") return this.stableGlobals.has(value.localId); - // So does the environment for a capture nothing ever rebinds. - if (binding.kind === "boxed") - return this.stableCaptureIds.has(value.localId) && this.canBorrowReceiver(value); - return ( - binding.kind === "local" && - binding.local !== undefined && - (!binding.local.mutable || - this.stableCallBindings.has(value.localId) || - this.borrowedParameters.has(value.localId) || - this.localUnionStorage.has(value.localId)) && - !binding.local.boxed && - !binding.local.tdz - ); + return canBorrowCallArgument(this, value); } /** A plain-pointer read of a construction-only field outside any * constructor (ImmutableFields). */ - private immutableRead(e: ExprOf<"fieldGet" | "recordGet">): boolean { + immutableRead(e: ExprOf<"fieldGet" | "recordGet">): boolean { if (this.inConstructor || this.immutableFields === null) return false; const t = e.type.kind; if (t !== "object" && t !== "record" && t !== "array" && t !== "string") return false; @@ -7775,7 +4020,7 @@ export class LlEmitter { * rest of its block (its whole lifetime) writes none of the projection's * locations: the source edge then keeps the value alive, and uses that * escape still retain their own copies. */ - private unwrittenAlias(s: IrStmt & { kind: "varDecl" }): boolean { + unwrittenAlias(s: IrStmt & { kind: "varDecl" }): boolean { const cursor = this.stmtCursor; const rw = this.referenceWrites; if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s) @@ -7794,7 +4039,7 @@ export class LlEmitter { * element when the rest of the block writes no array element and none of * the locations keeping the array alive (which must itself be read * without a frame owner). */ - private unwrittenLocalArrayRead(s: IrStmt & { kind: "varDecl" }, read: LocalArrayRead): boolean { + unwrittenLocalArrayRead(s: IrStmt & { kind: "varDecl" }, read: LocalArrayRead): boolean { const cursor = this.stmtCursor; const rw = this.referenceWrites; if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s) @@ -7824,7 +4069,7 @@ export class LlEmitter { * finally copies, like any pending return value, and the adopting await * runs afterwards with no handler in scope: a rejection settles the * function's promise instead of reaching its own catch clauses. */ - private emitAdoptingReturn(s: IrStmt & { kind: "return" }, adopted: ExprOf<"awaitExpr">): void { + emitAdoptingReturn(s: IrStmt & { kind: "return" }, adopted: ExprOf<"awaitExpr">): void { const B = this.B; const promise = this.emitExpr(adopted.value); this.toFrame(promise); @@ -7880,19 +4125,19 @@ export class LlEmitter { /** An async generator completing without a further turn: its request * settles as the body returns (scr_async_gen_hop_done). */ - private emitAsyncGenSettled(): void { + emitAsyncGenSettled(): void { this.declare(`declare void @scr_async_gen_hop_done()`); this.B.line(`call void @scr_async_gen_hop_done()`); } /** Owned-binding reads that take over the binding's reference, by read * node (the binding's slot). */ - private readonly lastUseMoves = new Map(); + readonly lastUseMoves = new Map(); /** How often each varRef node object occurs in the current function: * lowering may share one node between several reads, and a move keyed by * node identity must fire for exactly one of them. */ - private varRefCounts: Map | null = null; - private currentFunctionBody: IrStmt[] = []; + varRefCounts: Map | null = null; + currentFunctionBody: IrStmt[] = []; private varRefCount(e: IrExpr): number { if (this.varRefCounts === null) { @@ -7922,10 +4167,7 @@ export class LlEmitter { /** An owned local declared at the statement cursor whose last statement * reads it once (lastUseRead): that read moves the reference. */ - private noteLastUseMove( - s: IrStmt & { kind: "varDecl" }, - b: ReturnType, - ): void { + noteLastUseMove(s: IrStmt & { kind: "varDecl" }, b: ReturnType): void { const cursor = this.stmtCursor; if ( !this.lastUseMovesAllowed() || @@ -7947,7 +4189,7 @@ export class LlEmitter { * parameter's slot (they need it until the function returns), so bodies * with walks keep their parameters, and locals rebound from the * parameter count as derived from it. */ - private noteParameterMove(fn: IrFunction, localId: string, slot: string): void { + noteParameterMove(fn: IrFunction, localId: string, slot: string): void { if ( !this.lastUseMovesAllowed() || fn.async || @@ -7972,7 +4214,7 @@ export class LlEmitter { /** `return x` of a local whose slot is an owned scope entry of the same * type: the return can take over the local's reference. */ - private movableReturn( + movableReturn( value: IrExpr | null, ): { scope: LlScopeEntry[]; entry: LlScopeEntry; index: number } | null { if (value === null || value.kind !== "varRef" || !isRefCounted(value.type)) return null; @@ -7994,7 +4236,7 @@ export class LlEmitter { /** A walk host for a body that may write the heap: each walk must read * only locations the whole body (with its callees) never writes; * rebinding the walk locals themselves releases nothing. */ - private unwrittenWalkHost(fn: IrFunction, host: WalkBorrowHost): WalkBorrowHost { + unwrittenWalkHost(fn: IrFunction, host: WalkBorrowHost): WalkBorrowHost { return { ...host, unwritten: (value, candidates) => @@ -8094,7 +4336,7 @@ export class LlEmitter { // ── statements ────────────────────────────────────────────────────────── - private emitStmts(stmts: IrStmt[], scoped = false): void { + emitStmts(stmts: IrStmt[], scoped = false): void { const saved = this.stmtCursor; const cursor = scoped ? { stmts, index: 0 } : null; try { @@ -8117,7 +4359,7 @@ export class LlEmitter { * scope opens, before the statements — the catch-binding hook: it may * emit prelude lines and register entries the scope owns (released on * every exit, jumps and unwinds included). */ - private emitBlock(stmts: IrStmt[], setup?: (scope: LlScopeEntry[]) => void): void { + emitBlock(stmts: IrStmt[], setup?: (scope: LlScopeEntry[]) => void): void { const scope: LlScopeEntry[] = []; this.scopes.push(scope); setup?.(scope); @@ -8151,1674 +4393,21 @@ export class LlEmitter { if (loop) this.loopDepth--; } } - private emitStmtBody(s: IrStmt): void { - const B = this.B; - this.frames.push([]); - switch (s.kind) { - case "varDecl": { - if (s.init && this.int32Locals.has(s.localId)) { - this.storeInt32Local(s.localId, this.emitExpr(s.init), s.init); - break; - } - const b = this.binding(s.localId); - if (this.walkBorrows.has(s.localId) && b.kind === "local") { - // A borrowed walk pointer: no retain now, no release at scope exit. - const v = s.init === null ? "null" : this.emitReadReceiver(s.init).name; - B.line(`store ptr ${v}, ptr ${b.slot}`); - break; - } - const slice = this.scalarStringSlices.get(s.localId); - if (slice) { - const snapshot = emitStringSliceSnapshot(this, slice); - this.stringSlices.set(s.localId, snapshot); - this.scopes[this.scopes.length - 1]!.push({ slot: snapshot.source, type: STRING }); - break; - } - const split = this.privateSplitLocals.get(s.localId); - if (split) { - const snapshot = storeSplitSnapshot(this, emitSplitSnapshot(this, split)); - this.storedSplits.set(s.localId, snapshot); - this.scopes[this.scopes.length - 1]!.push( - { slot: snapshot.sourceSlot, type: STRING }, - { slot: snapshot.separatorSlot, type: STRING }, - ); - break; - } - const stackObject = this.localStackObjects.get(s.localId); - if (stackObject) { - const value = emitStackObject(this, stackObject, (fields) => this.ownScopeFields(fields)); - B.line(`store ptr ${value.name}, ptr ${b.slot}`); - break; - } - if (s.init && this.reassignedStackObjects.has(s.localId)) { - const slot = this.stackObjectSlot(s.localId, s.init.type); - emitStackObject( - this, - s.init as StackObjectSource, - (fields) => this.ownScopeFields(fields), - slot, - ); - B.line(`store ptr ${slot}, ptr ${b.slot}`); - break; - } - const localUnion = this.localStackUnions.get(s.localId); - if (localUnion) { - const union = emitStackUnion(this, localUnion); - B.line(`store ptr ${union.value.name}, ptr ${b.slot}`); - if (union.payload) { - this.toFrame(union.payload); - this.scopes[this.scopes.length - 1]!.push({ - slot: union.payloadSlot, - type: union.payload.type, - }); - } - break; - } - const localRead = this.localArrayReads.get(s.localId); - if (localRead) { - const owner = emitLocalArrayRead( - this, - !localRead.borrow && this.unwrittenLocalArrayRead(s, localRead) - ? { ...localRead, borrow: true } - : localRead, - b.slot, - ); - if (owner) this.scopes[this.scopes.length - 1]!.push(owner); - break; - } - const mapRead = this.mapReadLifetimes.locals.get(s.localId); - if (mapRead) { - const result = emitStackMapRead(this, mapRead); - B.line(`store ptr ${result.value.name}, ptr ${b.slot}`); - if (result.owner) this.scopes[this.scopes.length - 1]!.push(result.owner); - break; - } - const localStorage = this.localUnionStorageProofs.get(s.localId); - if (localStorage && s.init) { - const storage = allocateLocalUnion(this, localStorage); - this.localUnionStorage.set(s.localId, storage); - storeLocalUnion(this, storage, s.init); - B.line(`store ptr ${storage.box}, ptr ${b.slot}`); - if (storage.owner && storage.ownerType) - this.scopes[this.scopes.length - 1]!.push({ - slot: storage.owner, - type: storage.ownerType, - }); - break; - } - if (b.kind === "boxed") { - const stack = this.stackCaptures.emit(this, s.localId, b.type); - if (stack) { - B.line(`store ptr ${stack.box}, ptr ${b.slot}`); - if (stack.owner) this.scopes[this.scopes.length - 1]!.push(stack.owner); - if (s.init) { - const value = this.emitExpr(s.init); - if (isRefCounted(value.type)) this.toFrame(value); - this.boxSet(stack.box, b.type, value.name); - } - break; - } - // Box FIRST, then evaluate the initializer: a named function - // expression's closure captures this box during init evaluation. - // A SCALAR TDZ box rides an ARR-kind box: the value lives in a - // one-element array cell, so the empty (NULL) slot stays the - // not-yet-initialized sentinel — a raw scalar slot has no spare - // bit pattern to spend on it (stmts.ts's varDecl). - const boxNew = - b.local!.tdz === true && boxAccess(b.type) !== "ref" - ? (this.declare(`declare ptr @scr_box_new(i32)`), `call ptr @scr_box_new(i32 3)`) - : boxNewCall(this.shapeHost, b.type); - const box = B.tmp(); - B.line(`${box} = ${boxNew} ; let ${b.local!.name} (boxed)`); - B.line(`store ptr ${box}, ptr ${b.slot}`); - this.scopes[this.scopes.length - 1]!.push({ slot: b.slot, type: b.type, boxed: true }); - if (s.init === null) { - // An empty TDZ box whose payload can legitimately be NULL - // starts at its distinct sentinel (tdzEmptySentinel). - const sentinel = b.local!.tdz === true ? this.tdzEmptySentinel(b.type) : null; - if (sentinel !== null) B.line(`store ptr ${sentinel}, ptr ${this.boxSlot(box)}`); - break; - } - const v = this.emitExpr(s.init); - if (isRefCounted(v.type)) this.toHeap(v); // the box takes ownership - if (b.local!.tdz === true && boxAccess(b.type) !== "ref") { - this.tdzScalarInit(box, b.type, v.name); - } else { - this.boxSet(box, b.type, v.name); - } - break; - } - if (s.init === null) { - // Declared, uninitialized (`let x: number;`): reset the slot — - // inside a loop the previous iteration's scope exit released the - // old value and left a stale pointer (NULL-tolerant releases). - if (isRefCounted(b.type)) { - B.line(`store ptr null, ptr ${b.slot}`); - this.scopes[this.scopes.length - 1]!.push({ slot: b.slot, type: b.type }); - } - break; - } - if (this.loopArrayBorrows.has(s) && s.init.kind === "arrayGet") { - emitBorrowedArrayRead(this, s.init, b.slot); - break; - } - const holeRead = this.loopArrayBorrows.has(s) ? dynHoleRead(s.init) : null; - if (holeRead && s.init.kind === "ternary") { - // A hole reads as the immortal undefined value, which needs no owner. - const present = this.emitExpr(s.init.cond); - const filled = B.newLabel("borrow.hole.filled"), - missing = B.newLabel("borrow.hole.missing"), - done = B.newLabel("borrow.hole.done"); - B.condBr(present.name, filled, missing); - B.startBlock(filled); - emitBorrowedArrayRead(this, holeRead, b.slot); - B.br(done); - B.startBlock(missing); - this.declare("declare ptr @scr_dyn_undefined()"); - const undef = B.tmp(); - B.line(`${undef} = call ptr @scr_dyn_undefined()`); - B.line(`store ptr ${undef}, ptr ${b.slot}`); - B.br(done); - B.startBlock(done); - break; - } - // A stable lexical alias cannot outlive its unchanged local owner. - // Whole-value uses still retain their own copies, so returning or - // storing the alias preserves the ordinary heap representation. - // Boxed captures, reassigned sources and projections need ownership. - if ( - b.kind === "local" && - b.local && - isRefCounted(b.type) && - this.stableCallBindings.has(s.localId) && - (this.canBorrowCallArgument(s.init) || this.unwrittenAlias(s)) - ) { - const v = this.emitReadReceiver(s.init); - B.line(`store ptr ${v.name}, ptr ${b.slot}`); - break; - } - const v = this.emitExpr(s.init); - if (b.kind === "global") this.toHeap(v); - else this.toFrame(v); - B.line(`store ${this.llType(b.type)} ${v.name}, ptr ${b.slot}`); - this.storeIntegerView(s.localId, v, s.init); - if (isRefCounted(b.type)) { - this.scopes[this.scopes.length - 1]!.push({ slot: b.slot, type: b.type }); - this.noteLastUseMove(s, b); - } - break; - } - case "assign": { - if (this.int32Locals.has(s.localId)) { - this.storeInt32Local(s.localId, this.emitExpr(s.value), s.value); - break; - } - const owner = this.reboundOwnerSlots.get(s.localId); - if (owner !== undefined && !this.walkBorrows.has(s.localId)) { - // A rebound borrowed parameter: the new value moves into the owner - // slot, replacing (and releasing) the previous owned value. This - // precedes the in-place string append, which would release the - // caller's borrow. - const param = this.binding(s.localId); - const slot = param.slot; - const v = this.emitExpr(s.value); - this.toFrame(v); - const old = B.tmp(); - B.line(`${old} = load ptr, ptr ${owner}`); - this.releaseFrame(old, param.type); - B.line(`store ptr ${v.name}, ptr ${slot}`); - B.line(`store ptr ${v.name}, ptr ${owner}`); - break; - } - if (this.reassignedStackObjects.has(s.localId)) { - // The new value may read the old one: build it apart (its - // reference fields owned by this statement until it completes), - // then copy it over the binding's slot. The binding takes over the - // new fields; the old ones are released after the slot no longer - // holds them. - const struct = stackObjectStruct(s.value.type); - let owned: readonly StackObjectField[] = []; - const value = emitStackObject(this, s.value as StackObjectSource, (fields) => { - owned = fields; - for (const field of fields) this.ownSlot(field.ptr, field.type); - }); - const target = this.stackObjectSlot(s.localId, s.value.type); - const old = stackObjectFields(this, s.value.type, target).map((field) => { - const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${field.ptr}`); - return { name: t, type: field.type }; - }); - const bits = B.tmp(); - B.line(`${bits} = load ${struct}, ptr ${value.name}`); - B.line(`store ${struct} ${bits}, ptr ${target}`); - this.disownSlots(owned); - for (const field of old) this.releaseFrame(field.name, field.type); - break; - } - const localStorage = this.localUnionStorage.get(s.localId); - if (localStorage) { - storeLocalUnion(this, localStorage, s.value); - break; - } - const self = matchStringSelfConcat(s.localId, s.value); - if (self) { - this.emitStringSelfConcatAssign(s.localId, self.left, self.suffix, false); - break; - } - const b = this.binding(s.localId); - if (this.walkBorrows.has(s.localId) && b.kind === "local") { - // Rebinding a borrowed walk pointer releases nothing. - B.line(`store ptr ${this.emitReadReceiver(s.value).name}, ptr ${b.slot}`); - break; - } - const v = this.emitExpr(s.value); - if (b.kind === "global" && !s.initializes) this.checkGlobalTdz(s.localId); - if (b.kind === "boxed") { - this.writeBindingBox(this.loadBox(b.slot), b.local!, v.name, s.initializes); - // The RHS remains frame-owned until a possible TDZ throw passes. - if (isRefCounted(v.type)) this.toHeap(v); - break; - } - if (b.kind === "global") this.toHeap(v); - else this.toFrame(v); - if (isRefCounted(b.type)) { - const old = B.tmp(); - B.line(`${old} = load ptr, ptr ${b.slot}`); - if (b.kind === "global") this.releaseHeap(old, b.type); - else this.releaseFrame(old, b.type); - } - B.line(`store ${this.llType(b.type)} ${v.name}, ptr ${b.slot}`); - this.storeIntegerView(s.localId, v, s.value); - if (b.kind === "global" && this.immortalGlobals.has(s.localId)) { - // An Immutable module constant (contracts/check.ts proved it is - // fresh, publishable and never written): its graph becomes - // immortal, so reads skip reference counting and the collector - // never visits it. - const lines = this.publishWalkers.emitPublish(v.name, v.type, s.loc); - for (const line of lines) B.line(line.trimStart()); - // Reads of this global skip retains from here on, which is only - // sound once the graph really is immortal. Node.js never fails - // here, so a refusal means the contract proof was wrong: stop - // rather than keep running with a mortal graph read as immortal. - if (lines.length > 0) { - this.declare(`declare zeroext i1 @scr_exc_pending()`); - this.declare(`declare void @scr_trap(ptr)`); - const refused = B.tmp(); - B.line(`${refused} = call zeroext i1 @scr_exc_pending()`); - const stop = B.newLabel("immutable.refused"); - const done = B.newLabel("immutable.published"); - B.condBr(refused, stop, done); - B.startBlock(stop); - B.line( - `call void @scr_trap(ptr ${this.cstr("scriptc: internal error: an Immutable value could not be published\n")})`, - ); - B.terminate("unreachable"); - B.startBlock(done); - } - } - break; - } - case "exprStmt": - // A statement-position splice never observes its removed elements. - if (!emitDiscardedSplice(this, s.expr)) this.emitDiscarded(s.expr); - break; - case "arraySet": { - // Evaluation order matches JS: array, index, then value. Ownership - // of a refcounted value moves into the array (the runtime releases - // the replaced element itself). A local receiver that the index and - // value cannot rebind is read in place, as for field stores; the - // released element cannot reach that binding. - if (this.emitArraySelfConcat(s)) break; - const arr = this.emitStableReceiver(s.arr, [s.index, s.value]); - const idx = this.emitExpr(s.index); - const v = this.emitExpr(s.value); - if (s.arr.type.kind !== "array") - throw new InternalCompilerError("llvm emitter bug: arraySet on non-array"); - const acc = elemAccess(s.arr.type.elem); - this.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); - if (acc === "ref") this.toHeap(v); - emitDenseArraySet( - this, - arr.name, - idx, - s.index, - acc, - s.arr.type.elem, - v.name, - this.provenDenseIndex(s.arr, s.index), - ); - break; - } - case "arraySetLength": { - // Pop's `length = 0` store on an empty array only matters when the - // array can be frozen (published); its operand is a local. - if ( - s.pop === true && - s.length.kind === "numLit" && - s.length.value === 0 && - this.publishedTypes?.containerGuarded(s.arr.type) !== true - ) - break; - const arr = emitBorrowedInput(this, s.arr); - const length = this.emitExpr(s.length); - if (s.arr.type.kind !== "array") - throw new InternalCompilerError("llvm emitter bug: arraySetLength on non-array"); - this.emitPublishedArrayGuard(arr.name, s.arr.type, s.pop === true ? 2 : 3); - this.declare(`declare void @scr_arr_set_len(ptr, double)`); - // Assigning the current length (a reused work array reset to where - // it already is) changes nothing: the runtime's validation accepts - // exactly the value equal to the length (-0 included, NaN never), - // and no element is released. Everything else keeps the call. - const lenPtr = B.tmp(), - len = B.tmp(), - lenNumber = B.tmp(), - same = B.tmp(); - const resize = B.newLabel("arr.len.set"), - done = B.newLabel("arr.len.done"); - B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 1`); - this.markMemoryPointer(lenPtr, "array:header"); - B.line( - `${len} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}${this.arrayLengthRange}`, - ); - B.line(`${lenNumber} = ${sizeToDouble(this.sizeType)} ${this.sizeType} ${len} to double`); - B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`); - B.condBr(same, done, resize); - B.startBlock(resize); - if (s.pop === true) { - // A discarded pop's `length - 1` on a packed array removes the - // last element inline, then releases it. - const call = B.newLabel("arr.len.call"), - pop = B.newLabel("arr.len.pop"); - const lastNumber = B.tmp(), - dropsLast = B.tmp(); - B.line(`${lastNumber} = fsub double ${lenNumber}, 1.0`); - B.line(`${dropsLast} = fcmp oeq double ${length.name}, ${lastNumber}`); - B.condBr(dropsLast, pop, call); - B.startBlock(pop); - const elem = s.arr.type.elem; - const acc = elemAccess(elem); - const raw = emitDenseArrayPop(this, arr.name, acc, call, call); - if (acc === "ref" && isRefCounted(elem)) { - const old = B.tmp(); - B.line(`${old} = inttoptr i64 ${raw} to ptr`); - this.releaseHeap(old, elem); - } - B.br(done); - B.startBlock(call); - } - B.line(`call void @scr_arr_set_len(ptr ${arr.name}, double ${length.name})`); - this.emitPendingCheck(); - B.br(done); - B.startBlock(done); - break; - } - case "arraySetUndefined": - case "arrayDelete": { - const arr = emitBorrowedInput(this, s.arr); - const idx = this.emitExpr(s.index); - if (s.arr.type.kind !== "array") - throw new InternalCompilerError(`llvm emitter bug: ${s.kind} on non-array`); - this.emitPublishedArrayGuard( - arr.name, - s.arr.type, - s.kind === "arrayDelete" ? 5 : 0, - idx.name, - ); - const fn = s.kind === "arraySetUndefined" ? "scr_arr_set_undefined" : "scr_arr_delete"; - this.declare( - `declare ${s.kind === "arrayDelete" ? "zeroext i1" : "void"} @${fn}(ptr, double)`, - ); - if (s.kind === "arrayDelete") - B.line(`call zeroext i1 @${fn}(ptr ${arr.name}, double ${idx.name})`); - else B.line(`call void @${fn}(ptr ${arr.name}, double ${idx.name})`); - break; - } - case "bytesSet": { - // Typed-array element write: same evaluation order as arraySet; - // the value is a scalar (the kind-specific inline path coerces - // JS-exactly), so no ownership moves. Invalid writes are ignored. - // IrBytesElem is static, so never rediscover it through the - // generic runtime switch in a hot loop. - const arr = this.emitStableReceiver(s.arr, [s.index, s.value]); - const idx = this.emitExpr(s.index); - const v = this.emitExpr(s.value); - if (s.arr.type.kind !== "bytes") - throw new InternalCompilerError("llvm emitter bug: bytesSet on non-bytes"); - this.emitBytesSet( - s.arr.type.elem, - arr.name, - idx, - v, - s.index, - s.value, - this.bytesBounds.has(s), - ); - break; - } - case "fieldSet": - case "recordSet": { - // Evaluation order: obj, then value. New value moved in; the old - // value is released AFTER the field is overwritten (unlink-then- - // release — a release can trigger a cycle collection, which must - // never see a heap edge whose count was already given up). - // Classes and records share the struct layout, so one emission. - const nullable = - s.kind === "fieldSet" ? this.nullableFields.get(s.className, s.field) : null; - if (nullable && s.kind === "fieldSet") { - const obj = this.emitStableReceiver(s.obj, [s.value]); - // (The guard precedes the value here: the nullable store - // evaluates it itself.) - this.emitStoreGuard(obj.name, s); - this.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value); - break; - } - if (s.kind === "fieldSet" && this.int32Slots.isField(s.className, s.field)) { - const obj = this.emitStableReceiver(s.obj, [s.value]); - const v = this.emitExpr(s.value); - this.emitStoreGuard(obj.name, s); - const { ptr } = this.classFieldPtr(obj.name, s.className, s.field); - this.storeInt32Field(ptr, v, s.value); - break; - } - if (this.emitFieldSelfConcat(s)) break; - const obj = this.emitStableReceiver(s.obj, [s.value]); - const v = this.emitFieldValue( - s.value, - s.kind === "recordSet" - ? this.recordFieldInline(s.shapeId, s.field) - : this.inlineUnions.classField(s.className, s.field), - ); - if (s.kind === "fieldSet") this.emitStoreGuard(obj.name, s); - else if (this.publishedTypes?.recordGuarded(s.shapeId) === true) - this.emitPublishedFieldGuard(obj.name, null, s.field); - const { ptr, type, inline } = - s.kind === "fieldSet" - ? this.classFieldPtr(obj.name, s.className, s.field) - : this.recordFieldPtr(obj.name, s.shapeId, s.field); - // A result-slot entry fills zeroed caller storage, each field once. - if ( - s.kind === "recordSet" && - s.obj.kind === "varRef" && - s.obj.localId === RESULT_DST_ID && - this.currentFunctionName.startsWith(RESULT_ENTRY_PREFIX) - ) - this.initField(ptr, type, inline, v); - else this.replaceField(ptr, type, inline, v); - if ( - s.kind === "fieldSet" && - s.field === "name" && - this.classMeta.get(s.className)?.root.def.name === "%Error" - ) { - const present = this.classFieldPtr(obj.name, s.className, "%namePresent").ptr; - const enumerable = this.classFieldPtr(obj.name, s.className, "%nameEnumerable").ptr; - const wasPresent = B.tmp(), - wasEnumerable = B.tmp(), - isPresent = B.tmp(), - nextEnumerable = B.tmp(); - B.line(`${wasPresent} = load i8, ptr ${present}`); - B.line(`${wasEnumerable} = load i8, ptr ${enumerable}`); - B.line(`${isPresent} = icmp ne i8 ${wasPresent}, 0`); - B.line(`${nextEnumerable} = select i1 ${isPresent}, i8 ${wasEnumerable}, i8 1`); - B.line(`store i8 1, ptr ${present}`); - B.line(`store i8 ${nextEnumerable}, ptr ${enumerable}`); - } - break; - } - case "recordKeyDelete": { - // `delete obj[k]` on a pure index-signature shape: a Map delete on - // the overflow (key and value released; absent keys no-op). - const obj = this.emitExpr(s.obj); - const key = this.emitExpr(s.key); - if (this.publishedTypes?.recordGuarded(s.shapeId) === true) { - // Node: "Cannot delete property 'k' of #" (key known at - // run time only; the guard reports the frozen record generically). - this.emitPublishedFieldGuard(obj.name, null, "[key]"); - } - const ovf = this.recordOvfPtr(obj.name, s.shapeId); - this.declare(`declare zeroext i1 @scr_map_delete_str(ptr, ptr)`); - const t = B.tmp(); - B.line(`${t} = call zeroext i1 @scr_map_delete_str(ptr ${ovf}, ptr ${key.name})`); - break; - } - case "recordKeySet": { - // Dynamic-keyed record write (the C per-shape helper, inline): - // declared keys write through (same-typed — dyn-valued shapes, - // whose writes validate and can throw, stay refused), undeclared - // keys insert/replace in the overflow map. Evaluation order: obj, - // key, value; the write OWNS the value (+1 moves in). - const obj = this.emitExpr(s.obj); - const key = this.emitExpr(s.key); - const v = this.emitExpr(s.value); - if (this.publishedTypes?.recordGuarded(s.shapeId) === true) - this.emitPublishedFieldGuard(obj.name, null, "[key]"); - if (isRefCounted(v.type)) this.toHeap(v); - const shape = this.recordShape(s.shapeId); - // Signature-free shapes dispatch over their (one-typed) declared - // fields and TRAP on a miss (scr_record_key_miss — JS would add - // the property, which a monomorphic struct cannot); overflow - // shapes keep the map insert tail. - const iv = shape.indexValue ?? shape.fields[0]?.type; - if (!iv) - throw new InternalCompilerError( - `llvm emitter bug: keyed write on field-free non-overflow shape ${s.shapeId}`, - ); - const vAcc = iv.kind === "f64" ? "f64" : iv.kind === "bool" ? "bool" : "ref"; - if (s.overflowOnly === true) { - // A LITERAL key naming no declared field: a plain overflow - // insert — no field chain. - const ovf = this.recordOvfPtr(obj.name, s.shapeId); - this.mapSet(ovf, "str", vAcc, key.name, v.name); - break; - } - const join = B.newLabel("rks.j"); - if (iv.kind === "dyn" && shape.indexValue) { - // A dyn-valued shape (the C recordKeySetHelper's dyn arm, - // inline): declared keys VALIDATE the dyn value against the - // field's type first (dynCheck — a mismatched write throws the - // catchable TypeError and leaves the field untouched; JS would - // store anything, the documented divergence); undeclared keys - // insert the dyn value into the overflow map as-is. - this.declare(`declare void @scr_dyn_release(ptr)`); - for (const f of shape.fields) { - const hit = B.tmp(); - B.line( - `${hit} = call zeroext i1 ${this.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`, - ); - const lh = B.newLabel("rks.h"); - const ln = B.newLabel("rks.n"); - B.condBr(hit, lh, ln); - B.startBlock(lh); - const pathSlot = B.slot(); - B.entryAllocas.push(`${pathSlot} = alloca %ScrDynPath`); - const pp = B.tmp(); - const kp = B.tmp(); - const ip = B.tmp(); - B.line(`${pp} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 0`); - B.line(`store ptr null, ptr ${pp}`); - B.line(`${kp} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 1`); - B.line(`store ptr ${this.cstr(f.name)}, ptr ${kp}`); - B.line(`${ip} = getelementptr inbounds %ScrDynPath, ptr ${pathSlot}, i64 0, i32 2`); - B.line(`store ${this.sizeType} 0, ptr ${ip}`); - const helper = this.dyn.dynCheckHelper(f.type); - const fty = this.llType(f.type); - const nv = B.tmp(); - B.line( - `${nv} = call ${fty === "i1" ? "zeroext i1" : fty} @${helper}(ptr ${v.name}, ptr ${pathSlot})`, - ); - B.line(`call void @scr_dyn_release(ptr ${v.name})`); - // Mismatched write: TypeError pending, field untouched — the - // statement-level check below unwinds. - const pend = B.tmp(); - for (const line of this.pendingTestLines(pend)) B.line(line.trimStart()); - const lw = B.newLabel("rks.w"); - B.condBr(pend, join, lw); - B.startBlock(lw); - const { ptr, type, inline } = this.recordFieldPtr(obj.name, s.shapeId, f.name); - if (inline) { - // The slot copies the checked value's bits; nv is released below. - this.storeField(ptr, type, nv, true); - this.releaseFrame(nv, type); - } else if (isRefCounted(type)) { - const old = B.tmp(); - B.line(`${old} = load ptr, ptr ${ptr}`); - this.storeField(ptr, type, nv, false); - this.releaseHeap(old, type); - } else { - this.storeField(ptr, type, nv, false); - } - B.br(join); - B.startBlock(ln); - } - const ovf = this.recordOvfPtr(obj.name, s.shapeId); - this.mapSet(ovf, "str", vAcc, key.name, v.name); - B.br(join); - B.startBlock(join); - // MAY THROW exactly when a dyn value can validate against a - // declared field (stmts.ts's condition). - if (shape.fields.length > 0) this.emitPendingCheck(); - break; - } - for (const f of shape.fields) { - // typeEquals(f.type, iv) — the frontend fences everything else. - const hit = B.tmp(); - B.line( - `${hit} = call zeroext i1 ${this.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`, - ); - const lh = B.newLabel("rks.h"); - const ln = B.newLabel("rks.n"); - B.condBr(hit, lh, ln); - B.startBlock(lh); - const { ptr, type, inline } = this.recordFieldPtr(obj.name, s.shapeId, f.name); - if (inline) { - // The write owns the moved-in value; the slot copies its bits. - this.storeField(ptr, type, v.name, true); - this.releaseHeap(v.name, type); - } else if (isRefCounted(type)) { - const old = B.tmp(); - B.line(`${old} = load ptr, ptr ${ptr}`); - this.storeField(ptr, type, v.name, false); - this.releaseHeap(old, type); - } else { - this.storeField(ptr, type, v.name, false); - } - B.br(join); - B.startBlock(ln); - } - if (!shape.indexValue) { - // The MISS on a fixed shape: release the moved-in value, throw - // the catchable TypeError naming the key (scr_record_key_miss — - // JS would add the property, the documented divergence). - if (isRefCounted(iv)) this.releaseHeap(v.name, iv); - this.declare(`declare void @scr_record_key_miss(ptr)`); - B.line(`call void @scr_record_key_miss(ptr ${key.name})`); - B.br(join); - B.startBlock(join); - this.emitPendingCheck(); - break; - } - const ovf = this.recordOvfPtr(obj.name, s.shapeId); - this.mapSet(ovf, "str", vAcc, key.name, v.name); - B.br(join); - B.startBlock(join); - break; - } - case "block": { - if (s.labels === undefined) { - this.emitBlock(s.body); - break; - } - // A labeled block: `break lbl` inside branches to the end label. - const le = B.newLabel("blk.e"); - this.jumpTargets.push({ - kind: "block", - brkLabel: le, - contLabel: null, - labels: s.labels, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - finallyDepth: this.finallyStack.length, - }); - this.emitBlock(s.body); - this.jumpTargets.pop(); - B.br(le); - B.startBlock(le); - break; - } - case "if": { - const cond = this.emitCondition(s.cond); - const trueLabel = B.newLabel("if.t"); - const joinLabel = B.newLabel("if.j"); - const falseLabel = s.else_ ? B.newLabel("if.f") : joinLabel; - B.condBr(cond, trueLabel, falseLabel); - B.startBlock(trueLabel); - this.emitBlock(s.then); - B.br(joinLabel); - if (s.else_) { - B.startBlock(falseLabel); - this.emitBlock(s.else_); - B.br(joinLabel); - } - B.startBlock(joinLabel); - break; - } - case "while": { - const workerBudget = this.workerLoopBudget(s); - const le = B.newLabel("loop.e"); - this.emitLoopCopies(s, true, le, (exit) => { - const lc = B.newLabel("loop.c"); - const lb = B.newLabel("loop.b"); - const lp = workerBudget !== null ? B.newLabel("loop.p") : lc; - B.br(lc); - B.startBlock(lc); - B.condBr(this.emitCondition(s.cond), lb, exit); - B.startBlock(lb); - this.jumpTargets.push({ - kind: "loop", - brkLabel: exit, - contLabel: lp, - labels: s.labels, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - finallyDepth: this.finallyStack.length, - }); - this.emitBlock(s.body); - this.jumpTargets.pop(); - B.br(lp); - if (lp !== lc) { - B.startBlock(lp); - this.emitWorkerLoopCheck(workerBudget); - B.br(lc); - } - }); - B.startBlock(le); - break; - } - case "doWhile": { - const workerBudget = this.workerLoopBudget(s); - // Body first (runs at least once); continue jumps to the CONDITION. - const le = B.newLabel("loop.e"); - this.emitLoopCopies(s, true, le, (exit) => { - const lb = B.newLabel("loop.b"); - const lc = B.newLabel("loop.c"); - B.br(lb); - B.startBlock(lb); - this.jumpTargets.push({ - kind: "loop", - brkLabel: exit, - contLabel: lc, - labels: s.labels, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - finallyDepth: this.finallyStack.length, - }); - this.emitBlock(s.body); - this.jumpTargets.pop(); - B.br(lc); - B.startBlock(lc); - this.emitWorkerLoopCheck(workerBudget); - B.condBr(this.emitCondition(s.cond), lb, exit); - }); - B.startBlock(le); - break; - } - case "for": { - // The init's scope wraps the whole loop (break/continue must NOT - // release it — scopeDepth captured after the push, C parity). - const integerArrayLoop = - this.debug === null - ? matchIntegerArrayForLoop(s, this.currentLocals, this.integerArrayBindings) - : null; - const integerLoop = - integerArrayLoop ?? - (this.debug === null ? matchIntegerBytesForLoop(s, this.numericLocals) : null); - const countedLoop = - !integerLoop && this.countedLoopsEnabled - ? matchIntegerCountedForLoop( - s, - this.numericLocals, - this.integerRanges, - this.unaliasedArrays, - ) - : null; - const window = - countedLoop && this.byteWindowsEnabled - ? matchByteWindow( - s, - countedLoop, - this.numericLocals, - this.captureIds, - this.integerRanges, - this.byteWindowEntries.get(s) ?? new Set(), - ) - : null; - const denseShape = !this.denseLoopsEnabled - ? null - : integerArrayLoop - ? { - counter: integerArrayLoop.localId, - length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" }, - range: { min: 0, max: 4294967294 }, - } - : countedLoop && !countedLoop.guarded - ? countedDenseShape(s, countedLoop) - : null; - const dense = - denseShape && !window - ? matchDenseArrayLoop( - s, - denseShape, - this.numericLocals, - this.unaliasedArrays, - this.denseLoopEntries.get(s) ?? new Set(), - ) - : null; - if (dense) { - const valid = emitDenseLoopGuard(this, dense); - const fast = B.newLabel("dense.loop.fast"), - slow = B.newLabel("dense.loop.slow"), - done = B.newLabel("dense.loop.done"); - B.condBr(valid, fast, slow); - this.denseLoopsEnabled = false; - try { - B.startBlock(slow); - this.emitStmt(s); - B.br(done); - B.startBlock(fast); - this.denseLoop = dense; - this.emitStmt(s); - B.br(done); - } finally { - this.denseLoop = null; - this.denseLoopsEnabled = true; - } - B.startBlock(done); - break; - } - if (window) { - const valid = emitByteWindowGuard(this, window); - const fast = B.newLabel("bytes.window.fast"), - slow = B.newLabel("bytes.window.slow"), - done = B.newLabel("bytes.window.done"); - B.condBr(valid, fast, slow); - const originalRanges = this.integerRanges, - originalBounds = this.bytesBounds; - this.byteWindowsEnabled = false; - try { - B.startBlock(slow); - this.emitStmt(s); - B.br(done); - B.startBlock(fast); - this.integerRanges = window.ranges; - this.bytesBounds = new Set([...originalBounds, ...window.bounds]); - this.emitStmt(s); - B.br(done); - } finally { - this.integerRanges = originalRanges; - this.bytesBounds = originalBounds; - this.byteWindowsEnabled = true; - } - B.startBlock(done); - break; - } - let countedJoin: string | null = null; - if (countedLoop?.guarded) { - let safe = "true"; - if (countedLoop.limitGuarded) { - const bound = this.emitExpr(countedLoop.limit); - safe = B.tmp(); - B.line( - `${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`, - ); - } - if (countedLoop.startGuarded) { - // An exact integer of at most 2^53 - 1 in magnitude, and not -0 - // (the bit patterns of the value and its round trip agree). - // NaN and infinities fail the ordered range tests. - const start = this.emitExpr(countedLoop.start); - const low = B.tmp(), - high = B.tmp(), - raw = B.tmp(), - wide = B.tmp(), - back = B.tmp(), - bits = B.tmp(), - backBits = B.tmp(), - exact = B.tmp(), - inRange = B.tmp(), - ok = B.tmp(); - B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(-Number.MAX_SAFE_INTEGER)}`); - B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`); - B.line(`${inRange} = and i1 ${low}, ${high}`); - B.line(`${raw} = fptosi double ${start.name} to i64`); - B.line(`${wide} = freeze i64 ${raw}`); - B.line(`${back} = sitofp i64 ${wide} to double`); - B.line(`${bits} = bitcast double ${start.name} to i64`); - B.line(`${backBits} = bitcast double ${back} to i64`); - B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`); - B.line(`${ok} = and i1 ${inRange}, ${exact}`); - if (safe === "true") safe = ok; - else { - const both = B.tmp(); - B.line(`${both} = and i1 ${safe}, ${ok}`); - safe = both; - } - } - const fast = B.newLabel("counted.fast"); - const slow = B.newLabel("counted.slow"); - countedJoin = B.newLabel("counted.done"); - B.condBr(safe, fast, slow); - B.startBlock(slow); - this.countedLoopsEnabled = false; - this.emitStmt(s); - this.countedLoopsEnabled = true; - B.br(countedJoin); - B.startBlock(fast); - } - this.scopes.push([]); - let integerSlot: string | null = null; - let countedLimit: string | null = null; - if (integerLoop) { - integerSlot = B.slot(); - B.entryAllocas.push( - `${integerSlot} = alloca ${this.sizeType} ; integer induction ${this.currentLocals.get(integerLoop.localId)!.name}`, - ); - let start = "0"; - if (integerArrayLoop && s.init?.kind === "varDecl" && s.init.init) { - const value = this.emitExpr(s.init.init); - start = B.tmp(); - // The start is 0 or an enclosing array index plus one (at most - // 2^32 - 1): on 64-bit targets the signed conversion is exact - // and avoids x86-64's unsigned 64-bit conversion sequence. - const conversion = this.sizeType === "i64" ? "fptosi" : "fptoui"; - B.line(`${start} = ${conversion} double ${value.name} to ${this.sizeType}`); - this.integerArrayBindings.add(integerLoop.localId); - } - B.line(`store ${this.sizeType} ${start}, ptr ${integerSlot}`); - this.integerLoopBindings.set(integerLoop.localId, { - slot: integerSlot, - type: this.sizeType, - range: - this.denseLoop?.counter === integerLoop.localId - ? this.denseLoop.range - : { min: 0, max: integerArrayLoop ? 4294967294 : Number.MAX_SAFE_INTEGER - 1 }, - }); - } else if (countedLoop) { - integerSlot = B.slot(); - B.entryAllocas.push( - `${integerSlot} = alloca i64 ; integer induction ${this.currentLocals.get(countedLoop.localId)!.name}`, - ); - const startNumber = this.emitExpr(countedLoop.start); - const start = B.tmp(); - B.line(`${start} = fptosi double ${startNumber.name} to i64`); - B.line(`store i64 ${start}, ptr ${integerSlot}`); - this.integerLoopBindings.set(countedLoop.localId, { - slot: integerSlot, - type: "i64", - signed: true, - range: - this.denseLoop?.counter === countedLoop.localId - ? this.denseLoop.range - : countedLoop.range, - }); - countedLimit = emitCountedLoopLimit(this, countedLoop); - } else if (s.init) { - // A multi-declarator head shares the loop's scope. Emitting its - // IR block as an ordinary block would release captured/ref locals - // before the first condition. - const initializers = s.init.kind === "block" ? s.init.body : [s.init]; - for (const initializer of initializers) this.emitStmt(initializer); - } - const le = B.newLabel("loop.e"); - // JS `for (let i ...)`: each iteration gets a FRESH binding holding - // a copy of the previous one (closures made in iteration k keep - // seeing iteration k's value) — only observable, and only emitted, - // when a let variable is captured (boxed). Freshen before the first - // condition and again in the continue-target block before updating. - const initializers = s.init?.kind === "block" ? s.init.body : s.init ? [s.init] : []; - const capturedLets = initializers.flatMap((init) => { - const local = init.kind === "varDecl" ? this.currentLocals.get(init.localId) : undefined; - return local?.boxed && local.mutable ? [local] : []; - }); - const freshenBindings = (): void => { - for (const local of capturedLets) { - const slot = `%${mangleLocal(local.id)}`; - const fresh = B.tmp(); - const old = B.tmp(); - B.line( - `${fresh} = ${boxNewCall(this.shapeHost, local.type)} ; per-iteration ${local.name}`, - ); - B.line(`${old} = load ptr, ptr ${slot}`); - const val = this.boxGet(old, local.type); - this.boxSet(fresh, local.type, val); - this.declare(`declare void @scr_box_release(ptr)`); - B.line(`call void @scr_box_release(ptr ${old})`); - B.line(`store ptr ${fresh}, ptr ${slot}`); - } - }; - const workerBudget = this.workerLoopBudget(s); - freshenBindings(); - this.emitLoopCopies(s, capturedLets.length === 0, le, (exit) => { - const lc = B.newLabel("loop.c"); - const lb = B.newLabel("loop.b"); - const lu = - s.update || capturedLets.length > 0 || workerBudget !== null - ? B.newLabel("loop.u") - : lc; - B.br(lc); - B.startBlock(lc); - if (integerLoop && integerSlot) { - const receiver = this.emitStableReceiver(integerLoop.limitReceiver, []); - const lenPtr = B.tmp(); - const len = B.tmp(); - const index = B.tmp(); - const inBounds = B.tmp(); - B.line( - `${lenPtr} = getelementptr inbounds %${integerArrayLoop ? "ScrArr" : "ScrBytes"}, ptr ${receiver.name}, i64 0, i32 1`, - ); - if (integerArrayLoop) this.markMemoryPointer(lenPtr, "array:header"); - B.line( - `${len} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`, - ); - B.line(`${index} = load ${this.sizeType}, ptr ${integerSlot}`); - B.line(`${inBounds} = icmp ult ${this.sizeType} ${index}, ${len}`); - B.condBr(inBounds, lb, exit); - } else if (countedLoop && integerSlot && countedLimit) { - const index = B.tmp(); - const inBounds = B.tmp(); - B.line(`${index} = load i64, ptr ${integerSlot}`); - B.line( - `${inBounds} = icmp ${countedLoop.step > 0 ? "slt" : "sgt"} i64 ${index}, ${countedLimit}`, - ); - B.condBr(inBounds, lb, exit); - } else if (s.cond) B.condBr(this.emitCondition(s.cond), lb, exit); - else B.br(lb); - B.startBlock(lb); - this.jumpTargets.push({ - kind: "loop", - brkLabel: exit, - contLabel: lu, - labels: s.labels, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - finallyDepth: this.finallyStack.length, - }); - this.emitBlock(s.body); - this.jumpTargets.pop(); - B.br(lu); - if (lu !== lc) { - B.startBlock(lu); - this.emitWorkerLoopCheck(workerBudget); - freshenBindings(); - if (integerLoop && integerSlot) { - const old = B.tmp(); - const next = B.tmp(); - B.line(`${old} = load ${this.sizeType}, ptr ${integerSlot}`); - B.line(`${next} = add nuw ${this.sizeType} ${old}, 1`); - B.line(`store ${this.sizeType} ${next}, ptr ${integerSlot}`); - } else if (countedLoop && integerSlot) { - const old = B.tmp(); - const next = B.tmp(); - B.line(`${old} = load i64, ptr ${integerSlot}`); - B.line(`${next} = add nsw i64 ${old}, ${countedLoop.step}`); - B.line(`store i64 ${next}, ptr ${integerSlot}`); - } else if (s.update) this.emitStmt(s.update); - B.br(lc); - } - }); - B.startBlock(le); - this.releaseScope(this.scopes.pop()!); - if (integerLoop) this.integerLoopBindings.delete(integerLoop.localId); - if (countedLoop) this.integerLoopBindings.delete(countedLoop.localId); - if (integerArrayLoop) this.integerArrayBindings.delete(integerArrayLoop.localId); - if (countedJoin) { - B.br(countedJoin); - B.startBlock(countedJoin); - } - break; - } - case "forOf": { - // Ascending index loop; the length is re-read every iteration - // (JS-exact — pushes inside the body extend the iteration). The - // iterable temp lives in this statement's frame, so it is released - // when the whole loop ends (and by `return`'s frame sweep). - if (s.iterable.type.kind !== "array") - throw new LlvmUnsupportedError(`forOf:${s.iterable.type.kind}`, s.loc); - const elem = s.iterable.type.elem; - const stored = - s.iterable.kind === "varRef" ? this.storedSplits.get(s.iterable.localId) : undefined; - const direct = this.streamingSplitsEnabled ? stringSplit(s.iterable) : null; - const snapshot = stored - ? loadSplitSnapshot(this, stored) - : direct - ? emitSplitSnapshot(this, direct) - : null; - const arr = snapshot ? null : this.emitExpr(s.iterable); - const cursor = snapshot ? emitSplitCursor(this, snapshot) : null; - const scratch = snapshot ? emitSplitScratch(this) : null; - const deferred = snapshot && canDeferSplitPiece(s, this.currentLocals.get(s.localId)); - const spanLength = deferred ? B.slot() : null; - if (spanLength) B.entryAllocas.push(`${spanLength} = alloca ${this.sizeType}`); - const idxSlot = snapshot ? null : B.slot(); - if (idxSlot) { - B.entryAllocas.push(`${idxSlot} = alloca double`); - B.line(`store double ${f64Lit(0)}, ptr ${idxSlot}`); - } - const lc = B.newLabel("fof.c"); - const lb = B.newLabel("fof.b"); - const lu = B.newLabel("fof.u"); - const le = B.newLabel("fof.e"); - const workerBudget = this.workerLoopBudget(s); - B.br(lc); - B.startBlock(lc); - const inBounds = B.tmp(); - let cur: string; - if (snapshot && cursor && scratch) { - cur = spanLength - ? emitSplitSpanNext(this, snapshot, cursor, spanLength) - : emitSplitNext(this, snapshot, cursor, scratch); - B.line(`${inBounds} = icmp ne ptr ${cur}, null`); - } else { - const i = B.tmp(), - lenPtr = B.tmp(), - rawLen = B.tmp(), - len = B.tmp(); - B.line(`${i} = load double, ptr ${idxSlot}`); - // The inline form of scr_arr_len, like the `length` intrinsic. - B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr!.name}, i32 0, i32 1`); - this.markMemoryPointer(lenPtr, "array:header"); - B.line( - `${rawLen} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}${this.arrayLengthRange}`, - ); - B.line(`${len} = ${sizeToDouble(this.sizeType)} ${this.sizeType} ${rawLen} to double`); - B.line(`${inBounds} = fcmp olt double ${i}, ${len}`); - cur = i; - } - B.condBr(inBounds, lb, le); - B.startBlock(lb); - this.jumpTargets.push({ - kind: "loop", - brkLabel: le, - contLabel: lu, - labels: s.labels, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - finallyDepth: this.finallyStack.length, - }); - // The loop variable is a fresh const per iteration: its scope opens - // here, holds the (for ref elements: owned +1) current element, and - // releases it at the end of each iteration. - this.scopes.push([]); - const localInfo = this.currentLocals.get(s.localId); - const slot = `%${mangleLocal(s.localId)}`; - const borrowedElement = !snapshot && this.loopArrayBorrows.has(s); - if (!snapshot) { - const acc = elemAccess(elem); - const getter = borrowedElement ? "scr_arr_borrow_ref" : `scr_arr_get_${acc}`; - cur = emitDenseArrayGet( - this, - arr!.name, - { name: cur, type: { kind: "f64" } }, - undefined, - acc, - elem, - getter, - !borrowedElement, - ); - } - if (spanLength) { - const length = B.tmp(); - B.line(`${length} = load ${this.sizeType}, ptr ${spanLength}`); - this.splitSpans.set(s.localId, { bytes: cur, length, scratch: scratch! }); - cur = "null"; - } - if (localInfo?.boxed) { - // Captured loop variable: a fresh box per iteration, matching the - // fresh const binding. The box takes ownership of a ref element's - // +1 and is released with the iteration's scope. - const box = B.tmp(); - B.line(`${box} = ${boxNewCall(this.shapeHost, elem)} ; per-iteration ${localInfo.name}`); - this.boxSet(box, elem, cur); - B.line(`store ptr ${box}, ptr ${slot}`); - this.scopes[this.scopes.length - 1]!.push({ slot, type: elem, boxed: true }); - } else { - B.line(`store ${this.llType(elem)} ${cur}, ptr ${slot}`); - if (isRefCounted(elem) && !borrowedElement) - this.scopes[this.scopes.length - 1]!.push({ slot, type: elem }); - } - this.emitStmts(s.body); - if (spanLength) this.splitSpans.delete(s.localId); - const endedWithJump = endsWithJump(s.body); - const scope = this.scopes.pop()!; - if (!endedWithJump) this.releaseScope(scope); - this.jumpTargets.pop(); - B.br(lu); - B.startBlock(lu); - this.emitWorkerLoopCheck(workerBudget); - if (idxSlot) { - const i2 = B.tmp(), - i3 = B.tmp(); - B.line(`${i2} = load double, ptr ${idxSlot}`); - B.line(`${i3} = fadd double ${i2}, ${f64Lit(1)}`); - B.line(`store double ${i3}, ptr ${idxSlot}`); - } - B.br(lc); - B.startBlock(le); - break; - } - case "switch": - this.emitSwitch(s); - break; - case "break": { - // Unlabeled: the innermost loop OR switch (labeled blocks are - // skipped); labeled: the entry carrying the label. - let target: (typeof this.jumpTargets)[number] | undefined; - for (let i = this.jumpTargets.length - 1; i >= 0; i--) { - const t = this.jumpTargets[i]!; - if (s.label !== undefined ? t.labels?.includes(s.label) : t.kind !== "block") { - target = t; - break; - } - } - if (!target) throw new InternalCompilerError("llvm emitter bug: break target not found"); - this.emitFinallysForJump(target.finallyDepth, target.frameDepth, target.scopeDepth); - if (!B.isTerminated()) B.terminate(`br label %${target.brkLabel}`); - break; - } - case "continue": { - // Unlabeled: the innermost loop; labeled: the loop carrying the - // label (tsc + the validator guarantee it IS a loop). - let target: (typeof this.jumpTargets)[number] | undefined; - for (let i = this.jumpTargets.length - 1; i >= 0; i--) { - const t = this.jumpTargets[i]!; - if (t.kind === "loop" && (s.label === undefined || t.labels?.includes(s.label))) { - target = t; - break; - } - } - if (!target || target.contLabel === null) - throw new InternalCompilerError("llvm emitter bug: continue target not found"); - this.emitFinallysForJump(target.finallyDepth, target.frameDepth, target.scopeDepth); - if (!B.isTerminated()) B.terminate(`br label %${target.contLabel}`); - break; - } - case "return": { - // The value computes FIRST (an SSA temp — finally mutations of - // returned locals cannot change it, Node-exact), then every - // crossed finally runs innermost-first with the frames/scopes/ - // tryStack it sees truncated to its region (its releases already - // ran; a throw inside a copy propagates OUT of the completing - // try, past its own catch), then the function-level releases and - // the actual ret. The parked value owns a synthetic slot-backed - // scope entry during each copy so a throwing finally releases it. - const adopted = - this.finallyStack.length > 0 || this.tryStack.length > 0 - ? adoptingAwaitOf(s.value) - : null; - if (adopted !== null) { - this.emitAdoptingReturn(s, adopted); - break; - } - let v: LlValue | null = null; - const moved = - this.finallyStack.length === 0 && !this.currentUnionResult - ? this.movableReturn(s.value) - : null; - if (s.value !== null && this.currentUnionResult) { - // The tag and payload bits leave in registers; a heap box the - // value needed is released with its frame below. - const box = this.emitUnionProjection(s.value); - v = { name: this.scalarUnionPair(box.name, s.value.type), type: s.value.type }; - } else if (moved !== null) { - // Returning an owned local hands its reference to the caller: no - // retain now and no release of the local below. - const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${moved.entry.slot}`); - v = { name: t, type: s.value!.type }; - moved.scope.splice(moved.scope.indexOf(moved.entry), 1); - this.releaseForJump(0, 0); - moved.scope.splice(moved.index, 0, moved.entry); - } else if (s.value !== null && this.currentBorrowedReturn) { - // A borrowed-return body hands back its walk without a reference - // (it has no try statements, so no finally can intervene). An - // optional element read lends the element its array keeps alive. - const read = this.optionalArrayReads.get(s.value); - if (read) { - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ptr`); - emitLocalArrayRead(this, { ...read, borrow: true }, slot); - const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${slot}`); - v = { name: t, type: s.value.type }; - } else v = this.emitReadReceiver(s.value); - } else if (s.value !== null) { - v = this.emitExpr(s.value); - if (this.currentCoro !== null) this.toHeap(v); - else this.toFrame(v); - } - // An async generator's `return v` awaits v before any crossed - // finally runs. Past finally regions that turn is taken here and - // the request settles at completion; otherwise the completion's - // own turn is the await (nothing runs in between). - const genSettles = - this.currentAsyncGenerator && (s.awaited === true || this.finallyStack.length > 0); - // A completion turn that awaits x replaces a return() request's - // already-taken await. - if (this.currentAsyncGenerator && !genSettles) { - this.declare(`declare void @scr_async_gen_return_awaits()`); - B.line(`call void @scr_async_gen_return_awaits()`); - } - if (this.currentAsyncGenerator && s.awaited !== true && this.finallyStack.length > 0) { - if (this.wasi) this.emitWasiSuspend(null); - else { - this.declare(`declare void @scr_await_hop()`); - B.line(`call void @scr_await_hop()`); - } - } - if (this.finallyStack.length > 0) { - let pretSlot: string | null = null; - if (v !== null && isRefCounted(v.type) && !this.currentUnionResult) { - pretSlot = B.slot(); - B.entryAllocas.push(`${pretSlot} = alloca ptr ; pending return (through finally)`); - B.line(`store ptr ${v.name}, ptr ${pretSlot}`); - } - const savedFrames = this.frames; - const savedScopes = this.scopes; - const savedFinally = this.finallyStack; - const savedTry = this.tryStack; - for (let i = savedFinally.length - 1; i >= 0 && !B.isTerminated(); i--) { - const fin = savedFinally[i]!; - this.releaseForJump(fin.frameDepth, fin.scopeDepth); - this.frames = this.frames.slice(0, fin.frameDepth); - this.scopes = this.scopes.slice(0, fin.scopeDepth); - this.finallyStack = savedFinally.slice(0, i); - this.tryStack = savedTry.slice(0, fin.tryDepth); - if (pretSlot !== null) this.scopes.push([{ slot: pretSlot, type: v!.type }]); - this.emitBlock(fin.body); - if (pretSlot !== null) this.scopes.pop(); - } - if (!B.isTerminated()) this.releaseForJump(0, 0); - this.frames = savedFrames; - this.scopes = savedScopes; - this.finallyStack = savedFinally; - this.tryStack = savedTry; - } else if (moved === null) { - this.releaseForJump(0, 0); - } - if (!B.isTerminated()) { - if (genSettles) this.emitAsyncGenSettled(); - if (this.currentCoro !== null) { - this.emitWasiFulfill(v); - B.terminate(`br label %${this.currentCoro.finalLabel}`); - } else if (v === null) B.terminate("ret void"); - else if (this.currentInt32Result) - B.terminate(`ret i32 ${this.int32Argument(v, s.value!)}`); - else if (this.currentUnionResult) B.terminate(`ret ${SCALAR_UNION_RESULT} ${v.name}`); - else B.terminate(`ret ${this.llType(s.value!.type)} ${v.name}`); - } - break; - } - case "throw": { - // Evaluate, move ownership into the runtime's exception cell, then - // unwind unconditionally (the innermost try handler, or out of the - // function) — the same release path as return/break/continue. - const v = this.emitExpr(s.value); - if (isRefCounted(s.value.type)) this.toHeap(v); // the cell takes ownership - this.emitThrowValue({ name: v.name, type: s.value.type }); - this.emitUnwind(); - break; - } - case "rethrow": { - // Re-raise the saved snapshot (payload retained — the binding - // local releases with its scope) and unwind like `throw`. - const c = this.emitExpr({ kind: "varRef", localId: s.localId, type: CAUGHT, loc: s.loc }); - this.declare(`declare void @scr_rethrow(ptr)`); - B.line(`call void @scr_rethrow(ptr ${c.name})`); - this.emitUnwind(); - break; - } - case "runtimeFence": { - // The deferred JS compile fence: throw a catchable Error naming - // the construct (message) with the SC code stamped on `code`, - // then unwind exactly like `throw`. SCR_ERR_ERROR = 0. - const bytes = Buffer.byteLength(s.message, "utf8"); - this.declare(`declare void @scr_throw_error_msg_code(i32, ptr, ${this.sizeType}, ptr)`); - B.line( - `call void @scr_throw_error_msg_code(i32 0, ptr ${this.cstr(s.message)}, ${this.sizeType} ${bytes}, ptr ${this.cstr(s.code)})`, - ); - this.emitUnwind(); - break; - } - case "tryCatch": - this.emitTryCatch(s); - break; - default: { - // Statement coverage is now total (bytesSet closed the set) — - // keep throwing LlvmUnsupportedError for any future IR statement kind. - const rest: never = s; - const k = (rest as IrStmt).kind; - throw new LlvmUnsupportedError(`stmt:${k}`, (rest as IrStmt).loc); - } - } - const frame = this.frames.pop()!; - // return/throw already released their frames on the jump path; the - // fall-through releases after them would be dead double-release code. - if ( - s.kind !== "return" && - s.kind !== "throw" && - s.kind !== "rethrow" && - s.kind !== "runtimeFence" - ) { - this.releaseTemps(frame); - } + return emitStmtBody(this, s); } - - /** try/catch/finally via pending-flag unwinding — stmts.ts's - * emitTryCatch, block-flavored. Entering a try emits NO code: the try - * context is compile-time state (tryStack) redirecting unwinds inside - * the region to a label here. Shape: - * - * { try body } unwinds inside release frames/scopes down to - * this statement's depths, then br the handler - * br after (normal completion skips the handler) - * try.c: (emitted only when some unwind targets it) - * binding = scr_exc_take() (or scr_exc_clear() when bindingless) - * { catch body } - * after: { finally body } normal path - * br try.e - * try.fx: exception path: the pending exception is - * stash = scr_exc_take() STASHED across the finally body so the - * { finally body } body's own pending checks answer for - * scr_rethrow(stash) themselves; a throw inside REPLACES the - * stash (it unwinds through the synthetic - * try.e: scope entry) — JS's semantics exactly - * - * Abrupt completions inside tryBody/catchBody ride the finallyStack: - * returns inline copies at the return site, while break/continue use the - * shared region walk before branching to their resolved target. */ - private emitTryCatch(s: IrStmt & { kind: "tryCatch" }): void { - const B = this.B; - const hasCatch = s.catchBody !== null; - const hasFinally = s.finallyBody !== null; - const catchLabel = B.newLabel("try.c"); - const finExcLabel = B.newLabel("try.fx"); - const endLabel = B.newLabel("try.e"); - const afterTryLabel = hasFinally ? B.newLabel("try.f") : endLabel; - - const handler = { - label: hasCatch ? catchLabel : finExcLabel, - used: false, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - }; - if (hasFinally) { - this.finallyStack.push({ - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - tryDepth: this.tryStack.length, - body: s.finallyBody!, - }); - } - this.tryStack.push(handler); - this.emitBlock(s.tryBody); - this.tryStack.pop(); - B.br(afterTryLabel); // no-op when the try body already terminated - - // Exceptions raised in the CATCH body unwind to the exception-path - // finally (pending stays set through it) when one exists. - const excHandler = { - label: finExcLabel, - used: !hasCatch && handler.used, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - }; - - if (hasCatch && handler.used) { - B.startBlock(catchLabel); - if (hasFinally) this.tryStack.push(excHandler); - if (this.currentGenerator !== null) { - // Generator bodies: a pending GENRET sentinel (.return(v) - // injected at a yield) is a RETURN completion, not a throw — - // catch must not take it. Re-unwind past this handler (finally - // still runs — the unwind targets the exception-path finally or - // the enclosing context; the depths here equal the handler's). - this.declare(`declare zeroext i1 @scr_exc_genret_pending()`); - const gr = B.tmp(); - B.line(`${gr} = call zeroext i1 @scr_exc_genret_pending()`); - const lg = B.newLabel("try.gr"); - const lk = B.newLabel("try.gk"); - B.condBr(gr, lg, lk); - B.startBlock(lg); - this.emitUnwind(); - B.startBlock(lk); - } - if (s.catchLocalId !== null) { - // catch (e): the exception MOVES into the binding's snapshot box, - // owned by the catch body's scope (released on every exit — - // normal fall-through, jumps out, and unwinds from the body). - const slot = `%${mangleLocal(s.catchLocalId)}`; - this.declare(`declare ptr @scr_exc_take()`); - this.emitBlock(s.catchBody!, (scope) => { - const c = B.tmp(); - B.line(`${c} = call ptr @scr_exc_take() ; catch binding`); - if (this.currentLocals.get(s.catchLocalId!)?.boxed) { - const box = B.tmp(); - B.line(`${box} = ${boxNewCall(this.shapeHost, CAUGHT)} ; captured exception`); - this.boxSet(box, CAUGHT, c); - B.line(`store ptr ${box}, ptr ${slot}`); - scope.push({ slot, type: CAUGHT, boxed: true }); - } else { - B.line(`store ptr ${c}, ptr ${slot}`); - scope.push({ slot, type: CAUGHT }); - } - }); - } else { - this.declare(`declare void @scr_exc_clear()`); - B.line(`call void @scr_exc_clear() ; catch takes the exception`); - this.emitBlock(s.catchBody!); - } - if (hasFinally) this.tryStack.pop(); - B.br(afterTryLabel); // the catch's normal completion - } - if (hasFinally) this.finallyStack.pop(); - - if (hasFinally) { - B.startBlock(afterTryLabel); - this.emitBlock(s.finallyBody!); // normal path - B.br(endLabel); - if (excHandler.used) { - // The pending exception is STASHED across the finally body (a - // ScrCaught snapshot, re-raised after) so the body runs with a - // CLEAN cell — see stmts.ts's exception-path copy. The - // stash rides an alloca slot so a throw inside the body unwinds - // through the synthetic scope entry (replace semantics). - B.startBlock(finExcLabel); - this.declare(`declare ptr @scr_exc_take()`); - this.declare(`declare void @scr_rethrow(ptr)`); - this.declare(`declare void @scr_caught_release(ptr)`); - const stash = B.tmp(); - const stashSlot = B.slot(); - B.entryAllocas.push(`${stashSlot} = alloca ptr ; finally exception stash`); - B.line(`${stash} = call ptr @scr_exc_take() ; stash across finally`); - B.line(`store ptr ${stash}, ptr ${stashSlot}`); - this.scopes.push([{ slot: stashSlot, type: CAUGHT }]); - const suppressHandler = - s.suppressFinallyErrors || s.dropFinallyErrors - ? { - label: B.newLabel("try.fs"), - used: false, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - } - : null; - if (suppressHandler) this.tryStack.push(suppressHandler); - this.emitBlock(s.finallyBody!); - if (suppressHandler) this.tryStack.pop(); - this.scopes.pop(); // normal completion keeps the stash for the re-raise - B.line(`call void @scr_rethrow(ptr ${stash})`); - B.line(`call void @scr_caught_release(ptr ${stash})`); - this.emitUnwind(); - if (suppressHandler?.used) { - B.startBlock(suppressHandler.label); - const cleanup = s.dropFinallyErrors ? "scr_exc_keep_first" : "scr_exc_suppress"; - this.declare(`declare void @${cleanup}(ptr)`); - B.line(`call void @${cleanup}(ptr ${stash}) ; consumes stash`); - this.emitUnwind(); - } - } - B.startBlock(endLabel); - } else { - B.startBlock(endLabel); - } + emitTryCatch(s: IrStmt & { kind: "tryCatch" }): void { + return emitTryCatch(this, s); } - - /** JS-exact switch: lazily evaluated, arbitrary-expression case tests in - * source order, bodies falling through in source order until a break — - * Literal-only tests can use native dispatch; other tests keep a chain - * of conditional branches. All case bodies - * share ONE scope; because dispatch can jump PAST a varDecl into a later - * case, refcounted/boxed case-body locals are NULL-reset up front and - * the scope-exit releases rely on NULL tolerance. */ - private emitSwitch(s: IrStmt & { kind: "switch" }): void { - const B = this.B; - const discKind = s.disc.type.kind; - if (discKind !== "f64" && discKind !== "string" && discKind !== "bool") { - throw new LlvmUnsupportedError(`switch:${discKind}`, s.loc); - } - // The disc temp lives in the whole statement's frame: for a string - // discriminant it stays alive across every test and body, released - // when the switch statement ends (break lands past this statement's - // frame release — releaseForJump keeps the target's own frame). - const disc = this.emitExpr(s.disc); - for (const c of s.cases) { - for (const stmt of c.body) { - if (stmt.kind !== "varDecl") continue; - const local = this.currentLocals.get(stmt.localId)!; - if (local.boxed || isRefCounted(local.type)) { - B.line(`store ptr null, ptr %${mangleLocal(local.id)} ; case-scoped ${local.name}`); - } - } - } - const end = B.newLabel("sw.e"); - const caseLabels = s.cases.map(() => B.newLabel("sw.c")); - const defaultIdx = s.cases.findIndex((c) => c.test === null); - const fallback = defaultIdx >= 0 ? caseLabels[defaultIdx]! : end; - if (!emitLiteralSwitch(this, disc, s.disc, s.cases, caseLabels, fallback)) { - s.cases.forEach((c, i) => { - if (c.test === null) return; - // Lazy source-order test evaluation (a test after the match never - // runs). Each test's temps release right after its comparison. - this.frames.push([]); - const t = this.emitExpr(c.test); - const hit = B.tmp(); - if (c.test.kind === "strLit") { - B.line(`${hit} = call zeroext i1 ${this.literalEquals(c.test.value)}(ptr ${disc.name})`); - } else if (c.test.type.kind === "string") { - this.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); - B.line(`${hit} = call zeroext i1 @sc_str_eq(ptr ${disc.name}, ptr ${t.name})`); - } else if (c.test.type.kind === "bool") { - B.line(`${hit} = icmp eq i1 ${disc.name}, ${t.name}`); - } else { - B.line(`${hit} = fcmp oeq double ${disc.name}, ${t.name}`); - } - this.releaseTemps(this.frames.pop()!); - const next = B.newLabel("sw.t"); - B.condBr(hit, caseLabels[i]!, next); - B.startBlock(next); - }); - B.br(fallback); - } - - this.jumpTargets.push({ - kind: "switch", - brkLabel: end, - contLabel: null, - labels: s.labels, - frameDepth: this.frames.length, - scopeDepth: this.scopes.length, - finallyDepth: this.finallyStack.length, - }); - const scope: LlScopeEntry[] = []; - this.scopes.push(scope); - s.cases.forEach((c, i) => { - B.br(caseLabels[i]!); // the previous body's natural fall-through - B.startBlock(caseLabels[i]!); - this.emitStmts(c.body); - }); - this.jumpTargets.pop(); - this.scopes.pop(); - // Natural fall-off of the last body releases the shared scope; a jump - // already released it before jumping. - const lastBody = s.cases[s.cases.length - 1]?.body; - if (!lastBody || !endsWithJump(lastBody)) this.releaseScope(scope); - B.br(end); - B.startBlock(end); + emitSwitch(s: IrStmt & { kind: "switch" }): void { + return emitSwitch(this, s); } /** Evaluates a condition (IR conds are bool-typed) and releases its * temps BEFORE the branch — safe because the result is a scalar i1, and * required in loop-condition blocks (their temps must not survive into * later blocks across the back edge). */ - private emitCondition(cond: IrExpr): string { + emitCondition(cond: IrExpr): string { const v = this.emitExpr(cond); const frame = this.currentFrame(); this.releaseTemps(frame); @@ -9901,7 +4490,7 @@ export class LlEmitter { return this.canBorrowCallArgument(value); } - private emitDiscarded(e: IrExpr): void { + emitDiscarded(e: IrExpr): void { // A statement-position yield never reads the resumed value: it stays // in the IN slot until the next resume replaces it. if (e.kind === "yieldExpr" && e.captureCompletion === undefined && e.type.kind !== "void") { @@ -9956,81 +4545,19 @@ export class LlEmitter { ): LlValue { return emitStringExpr(this, e); } - - /** - * Lower canonical `target = target + suffix` after evaluating the old - * left side and suffix in JavaScript order. The snapshot stays owned by - * the statement frame while the destination relinquishes its CURRENT - * value, making the snapshot unique unless a real observable alias exists. - */ emitStringSelfConcatAssign( localId: string, left: IrExpr, suffix: IrExpr, retainForYield: boolean, ): LlValue { - const moved = this.emitMovedSelfConcat(localId, left, suffix, retainForYield); - if (moved) return moved; - const snapshot = this.emitExpr(left); - // A suffix of several parts, or a number, appends each operand to the - // snapshot directly (in place once the binding lets go of it) instead - // of first building the suffix as a separate string. - const parts = stringParts(suffix); - const mixed = parts.length > 1 || numberPart(parts[0]!) !== null; - const charCode = mixed ? null : this.emitSingleCharCode(suffix); - const inputs = mixed ? emitConcatInputs(this, parts) : []; - // The concat only reads the suffix; relinquishing the binding below - // runs no code that could rebind a borrowed local. - const right = mixed || charCode !== null ? null : emitStringInputs(this, [suffix])[0]!; - const b = this.binding(localId); - const B = this.B; - if (b.kind === "boxed") { - // set_ref(NULL) unlinks then releases the binding's post-suffix value. - this.boxSet(this.loadBox(b.slot), b.type, "null"); - } else { - const old = B.tmp(); - B.line(`${old} = load ptr, ptr ${b.slot}`); - B.line(`store ptr null, ptr ${b.slot}`); - if (b.kind === "global") this.releaseHeap(old, b.type); - else this.releaseFrame(old, b.type); - } - let raw: string; - if (charCode !== null) { - // The snapshot moves into the consuming character append. - this.toFrame(snapshot); - this.declare(`declare ptr @scr_str_concat_move_char_code(ptr, double)`); - raw = B.tmp(); - B.line( - `${raw} = call ptr @scr_str_concat_move_char_code(ptr ${snapshot.name}, double ${charCode.name})`, - ); - } else if (right === null) { - raw = emitMixedConcat(this, snapshot.name, inputs); - } else { - this.declare(`declare ptr @scr_str_concat(ptr, ptr)`); - raw = B.tmp(); - B.line(`${raw} = call ptr @scr_str_concat(ptr ${snapshot.name}, ptr ${right.name})`); - } - const result = this.own({ name: raw, type: left.type }); - if (retainForYield) { - const stored = - b.kind === "local" - ? this.retainFrame(result.name, result.type) - : this.retainHeap(result.name, result.type); - if (b.kind === "boxed") this.boxSet(this.loadBox(b.slot), b.type, stored); - else B.line(`store ptr ${stored}, ptr ${b.slot}`); - } else { - if (b.kind === "local") this.toFrame(result); - else this.toHeap(result); - if (b.kind === "boxed") this.boxSet(this.loadBox(b.slot), b.type, result.name); - else B.line(`store ptr ${result.name}, ptr ${b.slot}`); - } - return result; + return emitStringSelfConcatAssign(this, localId, left, suffix, retainForYield); } /** The code of a one-argument `String.fromCharCode(c)` suffix, emitted, so * `s += String.fromCharCode(c)` appends the character without building * the one-character string; null for any other suffix. */ - private emitSingleCharCode(suffix: IrExpr): LlValue | null { + emitSingleCharCode(suffix: IrExpr): LlValue | null { if (suffix.kind !== "libCall" || suffix.fn !== "string.fromCharCode") return null; const packed = suffix.args[0]; if (packed?.kind !== "arrayLit" || packed.elems.length !== 1 || packed.spreads?.length) @@ -10047,7 +4574,7 @@ export class LlEmitter { * copying the whole string on every append. Returns false (emitting * nothing) for every other store. */ - private emitFieldSelfConcat(s: Extract): boolean { + emitFieldSelfConcat(s: Extract): boolean { const suffix = matchFieldSelfConcat(s); if (!suffix || s.value.kind !== "strConcat") return false; let fieldType: IrType | undefined; @@ -10114,7 +4641,7 @@ export class LlEmitter { * the snapshot, the array lets go of it (emitDenseSlotUnlink) so the * concat can append in place, and the result is stored as usual. */ - private emitArraySelfConcat(s: Extract): boolean { + emitArraySelfConcat(s: Extract): boolean { const value = s.value; if ( value.kind !== "strConcat" || @@ -10165,7 +4692,7 @@ export class LlEmitter { * expressions): reading the binding after the suffix gives the value the * left side had, so it moves into a consuming concat, and the result takes * its place. Null when the general snapshot path is needed. */ - private emitMovedSelfConcat( + emitMovedSelfConcat( localId: string, left: IrExpr, suffix: IrExpr, @@ -10434,7 +4961,7 @@ export class LlEmitter { /** Lifetime regions in modules with lifetimeRegions (llvm/regions.ts). * Created with the class graph; null elsewhere. */ - private regions: LifetimeRegions | null = null; + regions: LifetimeRegions | null = null; /** LifetimeRegion's runtime calls: create answers the region id, enter * and leave bracket run(), release condemns. */ @@ -10465,10 +4992,10 @@ export class LlEmitter { return { name: "", type: e.type }; } - private publishWalkersCache: PublishWalkers | null = null; + publishWalkersCache: PublishWalkers | null = null; /** Per-type publication walkers, created on the first publish. */ - private get publishWalkers(): PublishWalkers { + get publishWalkers(): PublishWalkers { if (this.publishWalkersCache !== null) return this.publishWalkersCache; const byPre = [...this.classMeta.values()].sort((a, b) => a.pre - b.pre); this.publishWalkersCache = new PublishWalkers({ @@ -10553,7 +5080,7 @@ export class LlEmitter { private publishedNamesSym: string | null = null; /** The class-name table the hierarchy field guards index by preorder. */ - private publishedClassNamesDefs(): string[] { + publishedClassNamesDefs(): string[] { if (this.publishedNamesSym === null) return []; const metas = [...this.classMeta.values()]; const size = metas.reduce((max, m) => Math.max(max, m.pre + 1), 0); @@ -10567,7 +5094,7 @@ export class LlEmitter { /** The frozen-object guard of a class field store, unless the store * provably targets a still-private `this` (PublishedTypes.privateStores). */ - private emitStoreGuard(obj: string, s: Extract): void { + emitStoreGuard(obj: string, s: Extract): void { if (this.publishedTypes?.privateStores.has(s) === true) return; this.emitPublishedFieldGuard(obj, s.className, s.field); } @@ -10659,153 +5186,12 @@ export class LlEmitter { ): LlValue { return emitAsyncExpr(this, e); } - - /** Read a receiver whose value is consumed immediately by a field or tag - * load. No user code may run between this read and its consumer. Locals - * and their projections already have an owner; other expressions keep - * their ordinary statement-frame ownership. The caller must retain any - * reference result before evaluating another expression. */ emitReadReceiver(e: IrExpr): LlValue { - if (!isRefCounted(e.type)) return this.emitExpr(e); - if (e.kind === "strLit") return { name: this.internLiteral(e.value), type: e.type }; - const guarded = guardedValue(e); - if (guarded !== null) { - // Throwing guards run in place, exactly as the sequence emits them. - for (const s of (e as IrExpr & { kind: "seqExpr" }).stmts) this.emitStmt(s); - return this.emitReadReceiver(guarded); - } - if (e.kind === "varRef") { - this.materializeSplitLocal(e.localId); - const binding = this.binding(e.localId); - if (binding.kind === "boxed") { - // A projection consumes this pointer before any user code runs. - // Keep TDZ checks and caught-value conversion on the owned path. - if (!this.canBorrowReceiver(e)) return this.emitExpr(e); - const box = this.loadBox(binding.slot); - const payload = this.B.tmp(); - const value = this.B.tmp(); - this.B.line(`${payload} = getelementptr inbounds %ScrBox, ptr ${box}, i32 0, i32 5`); - this.B.line(`${value} = load ptr, ptr ${payload}`); - return { name: value, type: e.type }; - } - if (binding.kind === "global") this.checkGlobalTdz(e.localId); - const value = this.B.tmp(); - this.B.line(`${value} = load ptr, ptr ${binding.slot}`); - return { name: value, type: e.type }; - } - if (e.kind === "dynCheck" && e.exact) return this.emitExactCapsuleObject(e.value, e.type); - if (e.kind === "chainRecv") { - // The chain's bind slot holds its receiver unchanged until the body - // finishes, owned by the slot itself or by the chain's receiver. - const bound = this.chainSlots.get(e.id); - if (!bound) - throw new InternalCompilerError(`llvm emitter bug: chainRecv "${e.id}" outside its chain`); - const value = this.B.tmp(); - this.B.line(`${value} = load ${this.llType(bound.type)}, ptr ${bound.name}`); - return { name: value, type: e.type }; - } - if (e.kind === "arrayGet" && this.canBorrowReceiver(e)) { - // The array keeps the element alive until the consumer; a missing - // element still traps exactly like the owned read. - const slot = this.B.slot(); - this.B.entryAllocas.push(`${slot} = alloca ptr`); - emitBorrowedArrayRead(this, e, slot); - const value = this.B.tmp(); - this.B.line(`${value} = load ptr, ptr ${slot}`); - return { name: value, type: e.type }; - } - const wrap = this.borrowableNullableWrap(e); - if (wrap === "unit" && e.kind === "unionWrap") - return { name: this.unitInstanceRef(e.unionId, e.tag), type: e.type }; - if (wrap === "ref" && e.kind === "unionWrap") - return { name: this.emitReadReceiver(e.value).name, type: e.type }; - const read = matchMapRead(e, this.boxedUnionsById); - if (read) { - const result = emitStackMapRead(this, read); - if (result.owner) this.ownSlot(result.owner.slot, result.owner.type); - return result.value; - } - // A borrowed-return helper (such as an optional element read) lends its - // result like any borrowed-return call. - if (e.kind === "call" && this.borrowedReturns.has(e.callee) && this.canBorrowReceiver(e)) - return emitBorrowedResultCall(this, e); - const arrayRead = this.optionalArrayReads.get(e); - if (arrayRead) return emitProjectedArrayRead(this, arrayRead); - const narrow = this.checkedNarrows.get(e); - const narrowed = narrow ? emitCheckedNarrowReceiver(this, narrow) : null; - if (narrowed) return narrowed; - if (canStackUnion(e, this.boxedUnionsById)) return emitStackUnion(this, e).value; - if (e.kind === "unionNarrow") { - const union = this.emitUnionProjection(e.value); - emitUnionNarrowGuard(this, union.name, e.unionId, e.tag); - return { name: this.unionPeek(union.name, e.unionId), type: e.type }; - } - if ((e.kind === "downcast" || e.kind === "upcast") && e.value.type.kind === "object") { - // Prefix layouts: a class cast reads the operand's own pointer; a - // downcast verifies its target class (narrow-guards.ts). - const receiver = this.emitReadReceiver(e.value); - if (e.kind === "downcast" && e.type.kind === "object") - emitDowncastGuard(this, receiver.name, e.value.type.className, e.type.className); - return { name: receiver.name, type: e.type }; - } - // A nullable-pointer field has no box to borrow. Projections use - // emitUnionProjection; other borrowers get an owned heap box. - if (this.nullableFieldGet(e)) return this.emitExpr(e); - if (e.kind === "recordGet" || e.kind === "fieldGet") { - const receiver = this.emitReadReceiver(e.obj); - if (e.kind === "recordGet") { - // An inline union field answers a private stack box. - const { ptr, type, inline } = this.recordFieldPtr(receiver.name, e.shapeId, e.field); - return { name: this.loadRecordField(ptr, type, inline), type: e.type }; - } - const { ptr, type, inline } = this.classFieldPtr(receiver.name, e.className, e.field); - if (this.int32Slots.isField(e.className, e.field)) return this.loadInt32Field(ptr, e.type); - return { name: this.loadField(ptr, type, inline), type: e.type }; - } - if (e.kind === "ternary" && this.canBorrowReceiver(e.then) && this.canBorrowReceiver(e.else_)) { - // Checked projections lower to a ternary with an always-throwing - // arm. Keep that check at the use, but borrow the successful arm. - const B = this.B; - const cond = this.emitExpr(e.cond); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ptr`); - const yes = B.newLabel("read.then"), - no = B.newLabel("read.else"), - join = B.newLabel("read.join"); - B.condBr(cond.name, yes, no); - for (const [label, value] of [ - [yes, e.then], - [no, e.else_], - ] as const) { - B.startBlock(label); - this.frames.push([]); - const result = this.emitReadReceiver(value); - B.line(`store ptr ${result.name}, ptr ${slot}`); - this.releaseTemps(this.frames.pop()!); - B.br(join); - } - B.startBlock(join); - const value = B.tmp(); - B.line(`${value} = load ptr, ptr ${slot}`); - return { name: value, type: e.type }; - } - // Scalar optional values and register results answer a private stack - // box. A ternary of borrowable arms above keeps lending its arm. - if ( - (e.kind === "optChain" || - e.kind === "nullish" || - e.kind === "ternary" || - e.kind === "arrIntrinsic") && - isScalarOptionalSource(e, this.unionSources) - ) - return emitScalarOptional(this, e); - if (e.kind === "call" && this.scalarResultCall(e)) return emitCallExpr(this, e, false, true); - if (e.kind === "call" && this.canBorrowReceiver(e)) return emitBorrowedResultCall(this, e); - return this.emitExpr(e); + return emitReadReceiver(this, e); } /** The frame slot a reassigned stack-object local keeps for its value. */ - private stackObjectSlot(localId: string, type: IrType): string { + stackObjectSlot(localId: string, type: IrType): string { let slot = this.stackObjectSlots.get(localId); if (slot === undefined) { slot = this.B.slot(); @@ -10814,24 +5200,11 @@ export class LlEmitter { } return slot; } - - /** A private stack union source with an independently owned payload. */ canStackReceiver(e: IrExpr): boolean { - return this.isStackUnionSource(e); + return canStackReceiver(this, e); } - - /** True for union sources that emitReadReceiver can produce as a private - * stack box with an independently owned payload (no heap union). */ isStackUnionSource(e: IrExpr): boolean { - if (e.type.kind !== "union") return false; - return ( - canStackUnion(e, this.boxedUnionsById) || - matchMapRead(e, this.boxedUnionsById) !== null || - this.optionalArrayReads.get(e) !== null || - isScalarOptionalSource(e, this.unionSources) || - this.scalarResultCall(e) || - this.inlineFieldGet(e) - ); + return isStackUnionSource(this, e); } /** A read of a record or class field that stores its scalar union @@ -11007,72 +5380,8 @@ export class LlEmitter { this.B.line(`${object} = load ptr, ptr ${slot}`); return { name: object, type }; } - canBorrowReceiver(e: IrExpr): boolean { - switch (e.kind) { - case "chainRecv": - return this.chainSlots.has(e.id); - case "varRef": { - const binding = this.binding(e.localId); - return ( - binding.kind !== "boxed" || - (!binding.local!.tdz && - binding.type.kind === e.type.kind && - e.type.kind !== "caught" && - isRefCounted(e.type)) - ); - } - case "unionNarrow": - // Class casts reinterpret the same pointer (prefix layouts). - case "downcast": - case "upcast": - return this.canBorrowReceiver(e.value); - // An inline union field has no box to lend: projections read it - // through emitReadReceiver's private stack box. - case "fieldGet": - case "recordGet": - return !this.inlineFieldGet(e) && this.canBorrowReceiver(e.obj); - case "ternary": - return this.canBorrowReceiver(e.then) && this.canBorrowReceiver(e.else_); - case "seqExpr": { - const guarded = guardedValue(e); - return guarded !== null && this.canBorrowReceiver(guarded); - } - // The runtime always sets a pending exception; its unreachable - // typed dummy is null and owns no receiver. - case "libCall": - return e.fn === "error.nodeThrow"; - case "unionWrap": { - const wrap = this.borrowableNullableWrap(e); - return wrap === "unit" || (wrap === "ref" && this.canBorrowReceiver(e.value)); - } - // A borrowed-return result stays reachable from its borrowed - // arguments until the consumer runs. Each argument is lent without a - // snapshot, so neither the callee nor a later argument may replace - // what it was read from: a callee that preserves every edge cannot, - // and any other one must leave the argument's locations unwritten. - case "call": { - if (!this.borrowedReturns.has(e.callee)) return false; - if (!e.args.every((arg) => this.referenceEffects.preserves(arg))) return false; - const preserving = this.referenceEffects.functions.has(e.callee); - if (preserving) - return e.args.every((arg) => !isRefCounted(arg.type) || this.canBorrowReceiver(arg)); - if (this.referenceWrites === null) return false; - const lent = borrowableInputs(this, e.args, false, this.referenceWrites.summary(e.callee)); - return e.args.every((arg, i) => !isRefCounted(arg.type) || lent[i] === true); - } - // A required element read: the (borrowed) array owns the element - // while an edge-preserving index computes. - case "arrayGet": - return ( - e.arr.type.kind === "array" && - isRefCounted(e.arr.type.elem) && - this.canBorrowReceiver(e.arr) && - this.referenceEffects.preserves(e.index) - ); - default: - return false; - } + return canBorrowReceiver(this, e); } /** A reference held as one plain pointer that emitReadReceiver can read @@ -11092,65 +5401,8 @@ export class LlEmitter { return false; } } - - /** Functions returning a walk of their borrowed parameters return it - * without a reference (+0) from their borrowing body; direct callers - * either consume it as a receiver/walk or retain it at once, and the owned - * adapter retains before releasing its parameters. Candidates preserve - * every heap edge (so the result stays reachable from the arguments), - * borrow every reference parameter and return plain pointers. The set - * grows to a fixpoint because a call to a borrowed-return function with - * walk arguments is itself a walk; walk facts are then recomputed with - * the final set, admitting only parameters whose convention borrows. */ private analyzeBorrowedReturns(): void { - const pointer = (type: IrType): boolean => this.plainReference(type); - const borrowedIds = (fn: IrFunction): Set => { - const indexes = this.callLifetimes.borrowed.get(fn.name); - return new Set(fn.params.filter((_, i) => indexes?.has(i)).map((p) => p.localId)); - }; - const hostFor = (fn: IrFunction): WalkBorrowHost => { - const allowed = borrowedIds(fn); - const host = { - pointerLocal: (local: IrLocal) => pointer(local.type), - borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), - borrowedReturn: (callee: string) => this.borrowedReturns.has(callee), - parameterAllowed: (id: string) => allowed.has(id), - }; - return this.referenceEffects.functions.has(fn.name) ? host : this.unwrittenWalkHost(fn, host); - }; - // With ReferenceWrites, a body that writes none of its walks' locations - // qualifies like one that preserves every edge. - const preserving = [...this.fnByName.values()].filter( - (fn) => this.referenceEffects.functions.has(fn.name) || this.referenceWrites !== null, - ); - const candidates = preserving.filter((fn) => { - if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false; - // A borrowing vtable slot dispatches to the borrowing body (or its - // virtual adapter) directly, and virtual callers own the result. - if (this.implSlotBorrowed(fn.name).size > 0) return false; - const indexes = this.callLifetimes.borrowed.get(fn.name); - return ( - indexes !== undefined && - indexes.size > 0 && - fn.params.every((p, i) => !isRefCounted(p.type) || indexes.has(i)) - ); - }); - for (let changed = true; changed;) { - changed = false; - for (const fn of candidates) { - if (this.borrowedReturns.has(fn.name)) continue; - if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue; - this.borrowedReturns.add(fn.name); - // Nested borrowed returns extend the locations a caller depends on. - this.borrowedReturnKeyCache.clear(); - changed = true; - } - } - if (this.borrowedReturns.size === 0) return; - for (const fn of preserving) { - const walks = analyzeWalks(fn, hostFor(fn)).locals; - if (walks.size > 0) this.walkBorrowsByFunction.set(fn.name, walks); - } + return analyzeBorrowedReturns(this); } /** emitReadReceiver produces this node's pointer without acquiring a @@ -11462,7 +5714,7 @@ export class LlEmitter { this.B.line(`store i32 ${integer}, ptr ${slot}`); } - private emitBytesSet( + emitBytesSet( elem: IrBytesElem, receiver: string, index: LlValue, @@ -11601,7 +5853,7 @@ export class LlEmitter { /** The adopting await a `return` value resolves through, if any: the value * itself or the operand of its conversion into the declared result. */ -function adoptingAwaitOf(value: IrExpr | null): ExprOf<"awaitExpr"> | null { +export function adoptingAwaitOf(value: IrExpr | null): ExprOf<"awaitExpr"> | null { if (value === null) return null; let found: ExprOf<"awaitExpr"> | null = null; everyExpr(value, { diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts new file mode 100644 index 000000000..a1c771797 --- /dev/null +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -0,0 +1,409 @@ +/* LlEmitter's receivers: receiver reads, and the analyses that decide when calls, receivers and returns borrow. */ +import type { LlEmitter } from "./emitter.js"; +import { + type IrExpr, + type IrFunction, + type IrLocal, + type IrType, + isRefCounted, +} from "../../ir/ir.js"; +import type { LlValue } from "./expr-context.js"; +import { guardedValue } from "./borrowed-receivers.js"; +import { InternalCompilerError } from "../../errors.js"; +import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js"; +import { emitStackMapRead, matchMapRead } from "./map-read-lifetimes.js"; +import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js"; +import { emitCheckedNarrowReceiver } from "./checked-narrows.js"; +import { canStackUnion, emitStackUnion } from "./stack-unions.js"; +import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js"; +import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js"; +import { borrowableInputs } from "./borrowed-inputs.js"; +import { type WalkBorrowHost, analyzeWalks } from "./walk-borrows.js"; + +/** Read a receiver whose value is consumed immediately by a field or tag + * load. No user code may run between this read and its consumer. Locals + * and their projections already have an owner; other expressions keep + * their ordinary statement-frame ownership. The caller must retain any + * reference result before evaluating another expression. */ +export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue { + if (!isRefCounted(e.type)) return emitter.emitExpr(e); + if (e.kind === "strLit") return { name: emitter.internLiteral(e.value), type: e.type }; + const guarded = guardedValue(e); + if (guarded !== null) { + // Throwing guards run in place, exactly as the sequence emits them. + for (const s of (e as IrExpr & { kind: "seqExpr" }).stmts) emitter.emitStmt(s); + return emitter.emitReadReceiver(guarded); + } + if (e.kind === "varRef") { + emitter.materializeSplitLocal(e.localId); + const binding = emitter.binding(e.localId); + if (binding.kind === "boxed") { + // A projection consumes this pointer before any user code runs. + // Keep TDZ checks and caught-value conversion on the owned path. + if (!emitter.canBorrowReceiver(e)) return emitter.emitExpr(e); + const box = emitter.loadBox(binding.slot); + const payload = emitter.B.tmp(); + const value = emitter.B.tmp(); + emitter.B.line(`${payload} = getelementptr inbounds %ScrBox, ptr ${box}, i32 0, i32 5`); + emitter.B.line(`${value} = load ptr, ptr ${payload}`); + return { name: value, type: e.type }; + } + if (binding.kind === "global") emitter.checkGlobalTdz(e.localId); + const value = emitter.B.tmp(); + emitter.B.line(`${value} = load ptr, ptr ${binding.slot}`); + return { name: value, type: e.type }; + } + if (e.kind === "dynCheck" && e.exact) return emitter.emitExactCapsuleObject(e.value, e.type); + if (e.kind === "chainRecv") { + // The chain's bind slot holds its receiver unchanged until the body + // finishes, owned by the slot itself or by the chain's receiver. + const bound = emitter.chainSlots.get(e.id); + if (!bound) + throw new InternalCompilerError(`llvm emitter bug: chainRecv "${e.id}" outside its chain`); + const value = emitter.B.tmp(); + emitter.B.line(`${value} = load ${emitter.llType(bound.type)}, ptr ${bound.name}`); + return { name: value, type: e.type }; + } + if (e.kind === "arrayGet" && emitter.canBorrowReceiver(e)) { + // The array keeps the element alive until the consumer; a missing + // element still traps exactly like the owned read. + const slot = emitter.B.slot(); + emitter.B.entryAllocas.push(`${slot} = alloca ptr`); + emitBorrowedArrayRead(emitter, e, slot); + const value = emitter.B.tmp(); + emitter.B.line(`${value} = load ptr, ptr ${slot}`); + return { name: value, type: e.type }; + } + const wrap = emitter.borrowableNullableWrap(e); + if (wrap === "unit" && e.kind === "unionWrap") + return { name: emitter.unitInstanceRef(e.unionId, e.tag), type: e.type }; + if (wrap === "ref" && e.kind === "unionWrap") + return { name: emitter.emitReadReceiver(e.value).name, type: e.type }; + const read = matchMapRead(e, emitter.boxedUnionsById); + if (read) { + const result = emitStackMapRead(emitter, read); + if (result.owner) emitter.ownSlot(result.owner.slot, result.owner.type); + return result.value; + } + // A borrowed-return helper (such as an optional element read) lends its + // result like any borrowed-return call. + if (e.kind === "call" && emitter.borrowedReturns.has(e.callee) && emitter.canBorrowReceiver(e)) + return emitBorrowedResultCall(emitter, e); + const arrayRead = emitter.optionalArrayReads.get(e); + if (arrayRead) return emitProjectedArrayRead(emitter, arrayRead); + const narrow = emitter.checkedNarrows.get(e); + const narrowed = narrow ? emitCheckedNarrowReceiver(emitter, narrow) : null; + if (narrowed) return narrowed; + if (canStackUnion(e, emitter.boxedUnionsById)) return emitStackUnion(emitter, e).value; + if (e.kind === "unionNarrow") { + const union = emitter.emitUnionProjection(e.value); + emitUnionNarrowGuard(emitter, union.name, e.unionId, e.tag); + return { name: emitter.unionPeek(union.name, e.unionId), type: e.type }; + } + if ((e.kind === "downcast" || e.kind === "upcast") && e.value.type.kind === "object") { + // Prefix layouts: a class cast reads the operand's own pointer; a + // downcast verifies its target class (narrow-guards.ts). + const receiver = emitter.emitReadReceiver(e.value); + if (e.kind === "downcast" && e.type.kind === "object") + emitDowncastGuard(emitter, receiver.name, e.value.type.className, e.type.className); + return { name: receiver.name, type: e.type }; + } + // A nullable-pointer field has no box to borrow. Projections use + // emitUnionProjection; other borrowers get an owned heap box. + if (emitter.nullableFieldGet(e)) return emitter.emitExpr(e); + if (e.kind === "recordGet" || e.kind === "fieldGet") { + const receiver = emitter.emitReadReceiver(e.obj); + if (e.kind === "recordGet") { + // An inline union field answers a private stack box. + const { ptr, type, inline } = emitter.recordFieldPtr(receiver.name, e.shapeId, e.field); + return { name: emitter.loadRecordField(ptr, type, inline), type: e.type }; + } + const { ptr, type, inline } = emitter.classFieldPtr(receiver.name, e.className, e.field); + if (emitter.int32Slots.isField(e.className, e.field)) + return emitter.loadInt32Field(ptr, e.type); + return { name: emitter.loadField(ptr, type, inline), type: e.type }; + } + if ( + e.kind === "ternary" && + emitter.canBorrowReceiver(e.then) && + emitter.canBorrowReceiver(e.else_) + ) { + // Checked projections lower to a ternary with an always-throwing + // arm. Keep that check at the use, but borrow the successful arm. + const B = emitter.B; + const cond = emitter.emitExpr(e.cond); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + const yes = B.newLabel("read.then"), + no = B.newLabel("read.else"), + join = B.newLabel("read.join"); + B.condBr(cond.name, yes, no); + for (const [label, value] of [ + [yes, e.then], + [no, e.else_], + ] as const) { + B.startBlock(label); + emitter.frames.push([]); + const result = emitter.emitReadReceiver(value); + B.line(`store ptr ${result.name}, ptr ${slot}`); + emitter.releaseTemps(emitter.frames.pop()!); + B.br(join); + } + B.startBlock(join); + const value = B.tmp(); + B.line(`${value} = load ptr, ptr ${slot}`); + return { name: value, type: e.type }; + } + // Scalar optional values and register results answer a private stack + // box. A ternary of borrowable arms above keeps lending its arm. + if ( + (e.kind === "optChain" || + e.kind === "nullish" || + e.kind === "ternary" || + e.kind === "arrIntrinsic") && + isScalarOptionalSource(e, emitter.unionSources) + ) + return emitScalarOptional(emitter, e); + if (e.kind === "call" && emitter.scalarResultCall(e)) + return emitCallExpr(emitter, e, false, true); + if (e.kind === "call" && emitter.canBorrowReceiver(e)) return emitBorrowedResultCall(emitter, e); + return emitter.emitExpr(e); +} + +/** A local stable binding keeps its value alive while later arguments + * and the callee execute. A proven local union box cannot escape or be + * rebound during a call; its independent payload owner keeps it alive. + * Other writable bindings need an owned snapshot. */ +export function canBorrowCallArgument(emitter: LlEmitter, value: IrExpr): boolean { + if (value.kind === "strLit") return true; + // An exact capsule owns its instance while the capsule's binding holds it. + if (value.kind === "dynCheck" && value.exact) + return value.value.kind === "varRef" && emitter.canBorrowCallArgument(value.value); + // An inline union field has no box to lend. + if (emitter.inlineFieldGet(value)) return false; + // Class casts reinterpret the same pointer (prefix layouts). + if ((value.kind === "upcast" || value.kind === "downcast") && value.value.type.kind === "object") + return emitter.canBorrowCallArgument(value.value); + if (value.kind === "seqExpr") { + const guarded = guardedValue(value); + return guarded !== null && emitter.canBorrowCallArgument(guarded); + } + // A nullable-union wrap reinterprets its payload pointer; an immortal + // unit constant needs no owner at all. + const wrap = emitter.borrowableNullableWrap(value); + if (wrap === "unit") return true; + if (wrap === "ref" && value.kind === "unionWrap") + return emitter.canBorrowCallArgument(value.value); + // Checked casts (`x as C`, `x!` on a nullable) lower to a ternary whose + // failing arm always throws; the successful arm projects the same + // stable owner. The throwing arm's typed dummy owns nothing. + if (value.kind === "unionNarrow") return emitter.canBorrowCallArgument(value.value); + if (value.kind === "libCall") return value.fn === "error.nodeThrow"; + // A final field never changes outside constructors: while its object is + // borrowable, so is the field's value. Nullable-pointer fields have no + // box to lend and keep their owning path. + if ( + value.kind === "fieldGet" && + emitter.finalFieldBorrows && + isRefCounted(value.type) && + emitter.finalFields.isFinal(value.className, value.field) && + !emitter.nullableFieldGet(value) + ) + return emitter.canBorrowReceiver(value.obj) && emitter.canBorrowCallArgument(value.obj); + if (value.kind === "ternary") + return ( + emitter.canBorrowReceiver(value.then) && + emitter.canBorrowReceiver(value.else_) && + emitter.canBorrowCallArgument(value.then) && + emitter.canBorrowCallArgument(value.else_) + ); + // A construction-only field of a stable owner keeps its value until the + // owner dies: no later operand or callee can replace and release it. + if ((value.kind === "fieldGet" || value.kind === "recordGet") && emitter.immutableRead(value)) + return emitter.canBorrowCallArgument(value.obj); + // An optional chain's bound receiver stays unchanged until its body + // finishes (see emitReadReceiver). + if (value.kind === "chainRecv") return emitter.chainSlots.has(value.id); + if (value.kind !== "varRef") return false; + const binding = emitter.binding(value.localId); + // A module constant owns its value for the rest of the program. + if (binding.kind === "global") return emitter.stableGlobals.has(value.localId); + // So does the environment for a capture nothing ever rebinds. + if (binding.kind === "boxed") + return emitter.stableCaptureIds.has(value.localId) && emitter.canBorrowReceiver(value); + return ( + binding.kind === "local" && + binding.local !== undefined && + (!binding.local.mutable || + emitter.stableCallBindings.has(value.localId) || + emitter.borrowedParameters.has(value.localId) || + emitter.localUnionStorage.has(value.localId)) && + !binding.local.boxed && + !binding.local.tdz + ); +} + +export function canBorrowReceiver(emitter: LlEmitter, e: IrExpr): boolean { + switch (e.kind) { + case "chainRecv": + return emitter.chainSlots.has(e.id); + case "varRef": { + const binding = emitter.binding(e.localId); + return ( + binding.kind !== "boxed" || + (!binding.local!.tdz && + binding.type.kind === e.type.kind && + e.type.kind !== "caught" && + isRefCounted(e.type)) + ); + } + case "unionNarrow": + // Class casts reinterpret the same pointer (prefix layouts). + case "downcast": + case "upcast": + return emitter.canBorrowReceiver(e.value); + // An inline union field has no box to lend: projections read it + // through emitReadReceiver's private stack box. + case "fieldGet": + case "recordGet": + return !emitter.inlineFieldGet(e) && emitter.canBorrowReceiver(e.obj); + case "ternary": + return emitter.canBorrowReceiver(e.then) && emitter.canBorrowReceiver(e.else_); + case "seqExpr": { + const guarded = guardedValue(e); + return guarded !== null && emitter.canBorrowReceiver(guarded); + } + // The runtime always sets a pending exception; its unreachable + // typed dummy is null and owns no receiver. + case "libCall": + return e.fn === "error.nodeThrow"; + case "unionWrap": { + const wrap = emitter.borrowableNullableWrap(e); + return wrap === "unit" || (wrap === "ref" && emitter.canBorrowReceiver(e.value)); + } + // A borrowed-return result stays reachable from its borrowed + // arguments until the consumer runs. Each argument is lent without a + // snapshot, so neither the callee nor a later argument may replace + // what it was read from: a callee that preserves every edge cannot, + // and any other one must leave the argument's locations unwritten. + case "call": { + if (!emitter.borrowedReturns.has(e.callee)) return false; + if (!e.args.every((arg) => emitter.referenceEffects.preserves(arg))) return false; + const preserving = emitter.referenceEffects.functions.has(e.callee); + if (preserving) + return e.args.every((arg) => !isRefCounted(arg.type) || emitter.canBorrowReceiver(arg)); + if (emitter.referenceWrites === null) return false; + const lent = borrowableInputs( + emitter, + e.args, + false, + emitter.referenceWrites.summary(e.callee), + ); + return e.args.every((arg, i) => !isRefCounted(arg.type) || lent[i] === true); + } + // A required element read: the (borrowed) array owns the element + // while an edge-preserving index computes. + case "arrayGet": + return ( + e.arr.type.kind === "array" && + isRefCounted(e.arr.type.elem) && + emitter.canBorrowReceiver(e.arr) && + emitter.referenceEffects.preserves(e.index) + ); + default: + return false; + } +} + +/** Functions returning a walk of their borrowed parameters return it + * without a reference (+0) from their borrowing body; direct callers + * either consume it as a receiver/walk or retain it at once, and the owned + * adapter retains before releasing its parameters. Candidates preserve + * every heap edge (so the result stays reachable from the arguments), + * borrow every reference parameter and return plain pointers. The set + * grows to a fixpoint because a call to a borrowed-return function with + * walk arguments is itself a walk; walk facts are then recomputed with + * the final set, admitting only parameters whose convention borrows. */ +export function analyzeBorrowedReturns(emitter: LlEmitter): void { + const pointer = (type: IrType): boolean => emitter.plainReference(type); + const borrowedIds = (fn: IrFunction): Set => { + const indexes = emitter.callLifetimes.borrowed.get(fn.name); + return new Set(fn.params.filter((_, i) => indexes?.has(i)).map((p) => p.localId)); + }; + const hostFor = (fn: IrFunction): WalkBorrowHost => { + const allowed = borrowedIds(fn); + const host = { + pointerLocal: (local: IrLocal) => pointer(local.type), + borrowsWithoutOwning: (e: IrExpr) => emitter.borrowsWithoutOwning(e), + borrowedReturn: (callee: string) => emitter.borrowedReturns.has(callee), + parameterAllowed: (id: string) => allowed.has(id), + }; + return emitter.referenceEffects.functions.has(fn.name) + ? host + : emitter.unwrittenWalkHost(fn, host); + }; + // With ReferenceWrites, a body that writes none of its walks' locations + // qualifies like one that preserves every edge. + const preserving = [...emitter.fnByName.values()].filter( + (fn) => emitter.referenceEffects.functions.has(fn.name) || emitter.referenceWrites !== null, + ); + const candidates = preserving.filter((fn) => { + if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false; + // A borrowing vtable slot dispatches to the borrowing body (or its + // virtual adapter) directly, and virtual callers own the result. + if (emitter.implSlotBorrowed(fn.name).size > 0) return false; + const indexes = emitter.callLifetimes.borrowed.get(fn.name); + return ( + indexes !== undefined && + indexes.size > 0 && + fn.params.every((p, i) => !isRefCounted(p.type) || indexes.has(i)) + ); + }); + for (let changed = true; changed;) { + changed = false; + for (const fn of candidates) { + if (emitter.borrowedReturns.has(fn.name)) continue; + if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue; + emitter.borrowedReturns.add(fn.name); + // Nested borrowed returns extend the locations a caller depends on. + emitter.borrowedReturnKeyCache.clear(); + changed = true; + } + } + if (emitter.borrowedReturns.size === 0) return; + for (const fn of preserving) { + const walks = analyzeWalks(fn, hostFor(fn)).locals; + if (walks.size > 0) emitter.walkBorrowsByFunction.set(fn.name, walks); + } +} + +export function fieldAliasMetadata(emitter: LlEmitter): string[] { + if (emitter.fieldAliasTags.size === 0) return []; + const lines = ['!0 = !{!"scriptc native fields"}']; + for (const tag of emitter.fieldAliasTags.values()) { + lines.push( + `!${tag - 1} = !{!"field.${tag}", !0, i64 0}`, + `!${tag} = !{!${tag - 1}, !${tag - 1}, i64 0}`, + ); + } + return lines; +} + +/** A private stack union source with an independently owned payload. */ +export function canStackReceiver(emitter: LlEmitter, e: IrExpr): boolean { + return emitter.isStackUnionSource(e); +} + +/** True for union sources that emitReadReceiver can produce as a private + * stack box with an independently owned payload (no heap union). */ +export function isStackUnionSource(emitter: LlEmitter, e: IrExpr): boolean { + if (e.type.kind !== "union") return false; + return ( + canStackUnion(e, emitter.boxedUnionsById) || + matchMapRead(e, emitter.boxedUnionsById) !== null || + emitter.optionalArrayReads.get(e) !== null || + isScalarOptionalSource(e, emitter.unionSources) || + emitter.scalarResultCall(e) || + emitter.inlineFieldGet(e) + ); +} From 72eab2aa0e7e65e75cdd8db5590ce5cba31f5409 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 21:06:01 -0700 Subject: [PATCH 56/85] Document process.exitCode reads on the limitations page Co-Authored-By: Claude Opus 5.5 --- docs/content/limitations/builtin-apis/exit-status.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/content/limitations/builtin-apis/exit-status.mdx b/docs/content/limitations/builtin-apis/exit-status.mdx index d9bb779db..3cdf10496 100644 --- a/docs/content/limitations/builtin-apis/exit-status.mdx +++ b/docs/content/limitations/builtin-apis/exit-status.mdx @@ -1,3 +1,4 @@ - **Exit status:** Numeric `process.exitCode = value` writes in statement position set the implicit exit status, which `process.exit()` uses when called without an argument. `process.exit(code)` and `process.exitCode` throw a `RangeError` (`ERR_OUT_OF_RANGE`) for a code that is not a safe integer, and convert other codes to int32, as Node does. - Reads, resets, and assignments used as values are unsupported. + Reading `process.exitCode` answers the assigned code, or `undefined` before any assignment; `process.exit(code)` assigns it before the `'exit'` listeners run. + Resets (`process.exitCode = undefined`) and assignments used as values are unsupported. From ae83a53dad71764b421850201a876396571c8006 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:06:52 -0500 Subject: [PATCH 57/85] Give interfaces extending class-backed protocols checked identity An interface that extends a class-backed protocol, such as `interface TupleTypeReference extends TypeReference { getTarget(): TupleType }` over a `Type` that class instances flow into, mapped as a record when no class declared itself assignable to it. Asserting a base value to it copied the instance into that record, which has no methods: the next method call threw "expected function at $.getTarget, got undefined", or the assertion reported a conversion diagnostic. Such an interface holds values of its base protocol, so it now keeps the same checked identity: the assertion keeps the instance, and its methods and fields read as on the base type. --- .../src/frontend/lowering/class-protocols.ts | 6 ++ ...4-protocol-subinterface-assertions.ts.json | 6 ++ .../6064-protocol-subinterface-assertions.ts | 80 +++++++++++++++++++ ...4-protocol-subinterface-assertions.ts.json | 1 + 4 files changed, 93 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json create mode 100644 tests/corpus/6064-protocol-subinterface-assertions.ts create mode 100644 tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 5a27f0815..65d74ab9d 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -722,6 +722,12 @@ export class ClassProtocols { return true; } } + // An interface extending a class-backed protocol holds values of that + // protocol (`interface TupleType extends Type`), so an assertion from + // the base to it keeps the instance and its methods. + const target = type.isTypeReference() ? type.getTarget() : type; + if (target?.isClassOrInterface()) + for (const base of this.checker.getBaseTypes(target)) if (this.protocol(base)) return true; return false; } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json new file mode 100644 index 000000000..e81d7e9e7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6064-protocol-subinterface-assertions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6064-protocol-subinterface-assertions.ts b/tests/corpus/6064-protocol-subinterface-assertions.ts new file mode 100644 index 000000000..1d1656c5c --- /dev/null +++ b/tests/corpus/6064-protocol-subinterface-assertions.ts @@ -0,0 +1,80 @@ +// An interface that extends a class-backed protocol holds values of that +// protocol, so asserting a base-typed value to it (`type as +// TupleTypeReference`, where TupleTypeReference redeclares `getTarget(): +// TupleType`) keeps the class instance: its methods run and its fields read +// as on the base type. +interface Type { + readonly flags: number; + getTarget(): Type | undefined; + isTypeReference(): this is TypeReference; + isTupleType(): this is TupleType; +} +interface ObjectType extends Type {} +interface TypeReference extends ObjectType { + getTarget(): Type; +} +interface InterfaceType extends TypeReference {} +interface TupleType extends InterfaceType { + readonly elementFlags: readonly number[]; + readonly fixedLength: number; +} +interface TupleTypeReference extends TypeReference { + getTarget(): TupleType; +} +class CompilerType { + flags: number; + target: CompilerType | undefined; + elementFlags: number[] = []; + fixedLength = 0; + constructor(flags: number, target?: CompilerType) { + this.flags = flags; + this.target = target; + } + getTarget(): CompilerType | undefined { + return this.target; + } + isTypeReference(): boolean { + return (this.flags & 4) !== 0; + } + isTupleType(): boolean { + return (this.flags & 8) !== 0; + } +} +const tuple = new CompilerType(8); +tuple.elementFlags = [1, 1, 2]; +tuple.fixedLength = 2; +const reference = new CompilerType(4, tuple); +const publicType = (t: CompilerType): Type => t as unknown as Type; +function describe(type: Type): string { + if (type.isTypeReference() && type.getTarget().isTupleType()) { + const ref = type as TupleTypeReference; + const target = ref.getTarget(); + return `tuple ${target.fixedLength} [${target.elementFlags.join(",")}]`; + } + return `flags ${type.flags}`; +} +const seen = new Set([publicType(tuple)]); +console.log(describe(publicType(reference)), describe(publicType(tuple)), seen.has(publicType(tuple))); + +interface Shape { + readonly sides: number; + area(): number; +} +interface Square extends Shape { + readonly side: number; +} +class SquareImpl implements Shape { + readonly sides = 4; + readonly side: number; + constructor(side: number) { + this.side = side; + } + area(): number { + return this.side * this.side; + } +} +const shapes: Shape[] = [new SquareImpl(3), new SquareImpl(5)]; +for (const shape of shapes) { + const square = shape as Square; + console.log(square.sides, square.side, square.area(), square === shape); +} diff --git a/tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json b/tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json new file mode 100644 index 000000000..c05586e43 --- /dev/null +++ b/tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json @@ -0,0 +1 @@ +{ "plain": 4411, "san": 5159 } From 751d22ae6e64f7c6aff2e4543e4748ffd37820e2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:09:08 -0500 Subject: [PATCH 58/85] Pass this on to module exports only where every function that gets it ignores it The exemption for `f.apply(this, ...)` and `f.call(this, ...)` dropped SC1090 for every module export that passes `this` on. When the function it reaches reads `this`, Node gives it the module object, and the direct module call has none, so the result differed from Node. A `this` that is passed on now counts as unread only when the function it goes to provably ignores its `this`: an arrow, a function that uses `this` only to pass it on to such functions, or what a known function returns, with its parameters bound to the call's arguments and never assigned. The analysis follows at most six calls and functions, and refuses otherwise. Effect 4's provideService still compiles: it passes `this` to what dual(3, body) returns, which passes it on only to the arrow it was given. Passing `this` on to a function that reads it now refuses, directly, through such a factory, or when the factory reassigns its parameter. --- .../frontend/lowering/module-member-this.ts | 156 ++++++++++++++++-- tests/harness/errors.test.ts | 123 +++++++++++++- 2 files changed, 263 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/module-member-this.ts b/packages/compiler/src/frontend/lowering/module-member-this.ts index ce020d197..faa1341d8 100644 --- a/packages/compiler/src/frontend/lowering/module-member-this.ts +++ b/packages/compiler/src/frontend/lowering/module-member-this.ts @@ -26,8 +26,10 @@ function exportedFunction(decl: ts.Node): ts.Node | null { } /** Whether `node` is `f.apply(this, ...)` or `f.call(this, ...)`, which - * passes the receiver on without reading it. */ -function forwardsThis(node: ts.Node): node is ts.CallExpression { + * passes the receiver on to `f`. */ +function forwardsThis(node: ts.Node): node is ts.CallExpression & { + expression: ts.PropertyAccessExpression; +} { if (!ts.isCallExpression(node)) return false; const callee = node.expression; if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.name)) return false; @@ -35,30 +37,162 @@ function forwardsThis(node: ts.Node): node is ts.CallExpression { return node.arguments.nodes[0]?.kind === ts.SyntaxKind.ThisKeyword; } -/** Whether a function's own body reads `this` (arrows share it; other - * functions and classes have their own). A `this` that `.apply` or `.call` - * only passes on is not a read. */ -function readsOwnThis(fn: ts.Node): boolean { - let found = false; +/* The arguments a call gave a function's parameters, while the analysis + * follows the call into the function: each with the bindings of its own + * scope. */ +type Bindings = ReadonlyMap; +const noBindings: Bindings = new Map(); + +// How many calls and functions deep the analysis follows a forwarded +// `this`: enough for a wrapper over a function factory, as Effect's +// `dual(3, body).apply(this, arguments)`. +const maxDepth = 6; + +/** Whether `fn`'s own body uses `this` only to pass it on with `.apply` or + * `.call` to functions that ignore it (and so also when it doesn't use + * `this` at all). */ +function ignoresOwnThis(lowerer: Lowerer, fn: ts.Node, bindings: Bindings, depth: number): boolean { + let ignores = true; const visit = (node: ts.Node): void => { - if (found) return; + if (!ignores) return; if (node.kind === ts.SyntaxKind.ThisKeyword) { - found = true; + ignores = false; return; } if ((ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLikeDeclaration(node)) return; if (forwardsThis(node)) { - visit(node.expression); + if (!ignoresThis(lowerer, node.expression.expression, bindings, depth + 1)) { + ignores = false; + return; + } + visit(node.expression.expression); for (const arg of node.arguments.nodes.slice(1)) visit(arg); return; } node.forEachChild(visit); }; fn.forEachChild(visit); + return ignores; +} + +/** The function `name` provably holds: a function declaration, a const + * initialized with a function, or an import of one. */ +function functionOfName( + lowerer: Lowerer, + name: ts.Identifier, + bindings: Bindings, +): { fn: ts.Node | null; bound?: { expr: ts.Expression; bindings: Bindings } } { + let symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol === undefined) return { fn: null }; + const bound = bindings.get(symbol); + if (bound) return { fn: null, bound }; + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + const decls = symbol.declarations; + if (decls.length !== 1) return { fn: null }; + const decl = decls[0]!; + if (ts.isFunctionDeclaration(decl)) return { fn: decl }; + if ( + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (ts.isFunctionExpression(init) || ts.isArrowFunction(init)) return { fn: init }; + } + return { fn: null }; +} + +/** Whether `fn` (with its nested functions) assigns the binding `symbol`. */ +function assigns(lowerer: Lowerer, fn: ts.Node, symbol: ts.Symbol): boolean { + let found = false; + const isTarget = (node: ts.Node): boolean => + ts.isIdentifier(node) && lowerer.checker.getSymbolAtLocation(node) === symbol; + const visit = (node: ts.Node): void => { + if (found) return; + if ( + (ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + isTarget(node.left)) || + ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + isTarget(node.operand)) + ) { + found = true; + return; + } + node.forEachChild(visit); + }; + fn.forEachChild(visit); return found; } +/** The expressions `fn` returns from its own body. */ +function returnedExpressions(fn: ts.Node): ts.Expression[] | null { + const body = (fn as { body?: ts.Node }).body; + if (body === undefined) return null; + if (!ts.isBlock(body)) return [body as ts.Expression]; + const result: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression) result.push(node.expression); + node.forEachChild(visit); + }; + body.forEachChild(visit); + return result; +} + +/** Whether the function `expr` evaluates to provably ignores the `this` + * it is called with. */ +function ignoresThis( + lowerer: Lowerer, + expr: ts.Expression, + bindings: Bindings, + depth: number, +): boolean { + if (depth > maxDepth) return false; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (ts.isArrowFunction(expr)) return true; + if (ts.isFunctionExpression(expr)) return ignoresOwnThis(lowerer, expr, bindings, depth); + if (ts.isIdentifier(expr)) { + const { fn, bound } = functionOfName(lowerer, expr, bindings); + if (bound) return ignoresThis(lowerer, bound.expr, bound.bindings, depth + 1); + if (fn === null) return false; + return ts.isArrowFunction(fn) || ignoresOwnThis(lowerer, fn, noBindings, depth + 1); + } + // A call of a known function: what it returns, with its parameters bound + // to this call's arguments. + if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) { + const { fn } = functionOfName(lowerer, expr.expression, bindings); + if (fn === null) return false; + const params = (fn as { parameters?: ts.NodeArray }).parameters; + const inner = new Map(bindings); + for (const [i, param] of (params?.nodes ?? []).entries()) { + if (!ts.isIdentifier(param.name) || param.dotDotDotToken) return false; + const arg = expr.arguments.nodes[i]; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (arg === undefined || ts.isSpreadElement(arg) || symbol === undefined) return false; + // The parameter holds the argument for as long as the function runs. + if (assigns(lowerer, fn, symbol)) return false; + inner.set(symbol, { expr: arg, bindings }); + } + const returned = returnedExpressions(fn); + return returned !== null && returned.every((r) => ignoresThis(lowerer, r, inner, depth + 1)); + } + return false; +} + +/** Whether calling `fn` with another `this` than Node's can change what it + * does: it reads `this`, or passes it on to a function that may. */ +function readsOwnThis(lowerer: Lowerer, fn: ts.Node): boolean { + return !ignoresOwnThis(lowerer, fn, noBindings, 0); +} + export function refuseModuleMemberThis( lowerer: Lowerer, call: ts.CallExpression, @@ -70,7 +204,7 @@ export function refuseModuleMemberThis( if (symbol === undefined) return; for (const decl of symbol.declarations) { const fn = exportedFunction(decl); - if (fn === null || !readsOwnThis(fn)) continue; + if (fn === null || !readsOwnThis(lowerer, fn)) continue; lowerer.unsupported( "SC1090", call, diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 6a779528d..b2d5358b3 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1153,23 +1153,31 @@ export function plain(x) { return x + 1; } test("a namespace import's export that passes this on with apply or call compiles", async () => { // Effect 4's provideService: `dual(...).apply(this, arguments)` hands the - // receiver to a function that never reads it, as here. + // receiver to a function that `dual` returns, which reads it only to + // pass it on to an arrow, as here. const r = await compileAndRun( "namespace-member-forwards-this", `import * as ns from './lib.mjs'; -export const added = ns.provide(1, 2); +export const added = ns.provide(1, 2, 3); console.log(added, ns.sum(3, 4)); `, "mjs", { - "lib.mjs": `const dual = (body) => function () { return body.apply(this, arguments); }; -export const provide = function () { return dual((a, b) => a + b).apply(this, arguments); }; + "lib.mjs": `const dual = function (arity, body) { + switch (arity) { + case 2: + return function (a, b) { return arguments.length >= 2 ? body(a, b) : (self) => body(self, a); }; + default: + return function () { return body.apply(this, arguments); }; + } +}; +export const provide = function () { return dual(3, (a, b, c) => a + b + c).apply(this, arguments); }; const twice = function (a, b) { return (a + b) * 2; }; export const sum = function (a, b) { return twice.call(this, a, b); }; `, }, ); - expect(r).toEqual({ stdout: "3 14\n", stderr: "", exitCode: 0 }); + expect(r).toEqual({ stdout: "6 14\n", stderr: "", exitCode: 0 }); }); test("a require binding's export that passes this on with apply compiles", async () => { @@ -1190,6 +1198,111 @@ exports.scale = function () { return triple.apply(this, arguments); }; expect(r).toEqual({ stdout: "15\n", stderr: "", exitCode: 0 }); }); + test.each([ + [ + "a namespace import's export that passes this on to a function that reads it", + "namespace-member-forwards-to-reader", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaCall('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +function read(suffix) { return this.tag + suffix; } +export function viaCall(suffix) { return read.call(this, suffix); } +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaCall", + 3, + ], + [ + "a namespace import's export that passes this on through a factory to a function that reads it", + "namespace-member-forwards-through-factory", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaDual('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const dual = function (arity, body) { + switch (arity) { + case 2: + return function (a, b) { return arguments.length >= 2 ? body(a, b) : (self) => body(self, a); }; + default: + return function () { return body.apply(this, arguments); }; + } +}; +export const viaDual = function () { return dual(9, function (s) { return this.tag + s; }).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaDual", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that reassigns its parameter", + "namespace-member-forwards-reassigned", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + body = function (s) { return this.tag + s; }; + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a require binding's export that passes this on to a function that reads it", + "require-member-forwards-to-reader", + "cjs", + `'use strict'; +const m = require('./lib.cjs'); +console.log(m.plain(21)); +console.log(m.viaApply('!')); +`, + { + "lib.cjs": `'use strict'; +exports.name = 'lib'; +const read = function (suffix) { return this.name + suffix; }; +exports.viaApply = function () { return read.apply(this, arguments); }; +exports.plain = function (x) { return x * 2; }; +`, + }, + "42\n", + "viaApply", + 4, + ], + ] as const)( + "%s is a named deferred fence", + async (_, name, ext, source, files, stdout, member, line) => { + // Node passes the module object on to `read`, which reads it; the + // direct call has none to pass. + const r = await compileAndRun(name, source, ext, files); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(stdout); + expect(r.stderr).toMatch( + new RegExp( + `^Uncaught Error: calling a module export that reads 'this' through its module binding \\('${member}': .*\\) is not supported yet \\[SC1090 at .*${name}\\.${ext}:${line}\\]\\n$`, + ), + ); + }, + ); + test("an export that also reads this beside passing it on is a named deferred fence", async () => { const r = await compileAndRun( "namespace-member-forwards-and-reads-this", From 7e0106722bbeed3f2b4582acc1dda83433c0e39e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:18:55 -0500 Subject: [PATCH 59/85] Widen an unjudged backlog threshold with its level's growth fraction The backlog threshold of a level backs off by doublings as backlog passes find little to free, but only passes that had candidates count. A program whose mature passes have no candidates while its heap grows keeps the base threshold, a quarter of the live heap, while those same passes widen the level's growth fraction. When it then reads the structure it built, the mature buffer fills with live objects and the first backlog pass walks a quarter of the live heap to find nothing. In tsc-ts's program build on Sentry, that pass ran inside the loader walk and took 9 to 18 ms. Until a pass at a level has had candidates, the backlog threshold now uses the level's growth fraction doublings. The first pass with candidates judges the level, and the threshold follows its own yield from then on. --- packages/runtime/src/scr_cycle.c | 32 ++++++++++++++++++++++++++---- packages/runtime/test/test_cycle.c | 30 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index d700e6ba8..fd5d64c81 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -121,7 +121,9 @@ * mature the same two rules one level down: a growth fraction * measured from the last mature-or-full pass and scheduled * by everything mature passes free, and a backlog rule over - * the mature buffer. + * the mature buffer. Until a mature pass has had + * candidates, the backlog threshold widens with the growth + * fraction (scr_cyc_backlog_threshold). * scheduled age a mature or old candidate has waited through a nursery's * worth of event-loop checkpoints. Candidate COUNT cannot * bound the garbage behind one root, and an idle heap does @@ -217,6 +219,7 @@ typedef struct { unsigned char growth_shift[SCR_CYC_NGENS]; unsigned char growth_streak[SCR_CYC_NGENS]; unsigned backlog_shift[SCR_CYC_NGENS]; + bool backlog_judged[SCR_CYC_NGENS]; size_t fallen; size_t live_seen; size_t full_after; @@ -256,6 +259,7 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_growth_shift (scr_cyc_thread.growth_shift) #define scr_cyc_growth_streak (scr_cyc_thread.growth_streak) #define scr_cyc_backlog_shift (scr_cyc_thread.backlog_shift) +#define scr_cyc_backlog_judged (scr_cyc_thread.backlog_judged) #define scr_cyc_trigger (scr_cyc_thread.trigger) #define scr_cyc_alloc_trigger (scr_cyc_thread.alloc_trigger) #define scr_gray_referenced (scr_cyc_thread.gray_referenced) @@ -535,11 +539,13 @@ static unsigned scr_cyc_growth_max_shift(void) { } /* Doublings of each generation's backlog threshold earned by consecutive - * passes that found almost nothing to free (see scr_cyc_backlog_threshold). + * passes that found almost nothing to free (see scr_cyc_backlog_threshold), + * and whether a pass at that level has had candidates to judge them by. * Only the mature and old entries are used. */ #define SCR_CYC_BACKLOG_MAX_SHIFT 8 #ifndef SCR_WORKERS static SCR_TL unsigned scr_cyc_backlog_shift[SCR_CYC_NGENS]; +static SCR_TL bool scr_cyc_backlog_judged[SCR_CYC_NGENS]; #endif /* Mature objects in the last full pass's dead set (scr_cyc_adapt_growth). */ @@ -684,11 +690,21 @@ static SCR_CYC_SCHEDULING bool scr_cyc_grown(unsigned gen) { * this trigger rests, leaving heap growth, the scheduled age and exit to * collect. That only delays garbage made from existing data: replacing it * requires allocation, which grows the heap and trips a growth trigger. The - * first productive pass at that level restores the original fraction. */ + * first productive pass at that level restores the original fraction. + * + * Only passes that had candidates adapt the threshold. Until one has, the + * level uses its growth fraction's doublings instead (scr_cyc_adapt_growth), + * which every growth-triggered pass at the level adapts, candidates or not. + * A program can keep a level's buffer empty while its heap grows, so that + * growth passes there find nothing and widen, and then fill the buffer with + * live objects (a structure it builds and reads as it builds it); at the + * base threshold, the first backlog pass would walk a quarter of the live + * heap to find nothing. */ static size_t scr_cyc_backlog_threshold(unsigned gen) { size_t t = scr_cyc_live / SCR_CYC_GROWTH_DIV; if (t < SCR_CYC_NURSERY_CANDIDATES) t = SCR_CYC_NURSERY_CANDIDATES; - unsigned shift = scr_cyc_backlog_shift[gen]; + unsigned shift = + scr_cyc_backlog_judged[gen] ? scr_cyc_backlog_shift[gen] : scr_cyc_growth_shift[gen]; return t > (SIZE_MAX >> shift) ? SIZE_MAX : t << shift; } @@ -788,6 +804,7 @@ static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_grow size_t old_reclaimed = scr_cyc_old_freed - old_freed_before + scr_cyc_mature_dead; if (gen == SCR_CYC_NURSERY) return; if (scr_cands.n != 0) { + scr_cyc_backlog_judged[gen] = true; if (freed * SCR_CYC_GROWTH_DIV >= scr_cands.n) scr_cyc_backlog_shift[gen] = 0; else if (scr_cyc_backlog_shift[gen] < SCR_CYC_BACKLOG_MAX_SHIFT) @@ -2088,6 +2105,13 @@ size_t scr_cyc_pass_at(unsigned gen) { return scr_cyc_pass(gen); } /* Tests only: route appended candidates of bypassing types, as the next * scheduling decision or pass would. */ void scr_cyc_route_bypassed_at(void) { scr_cyc_route_bypassed(); } +/* Tests only: the backlog threshold at `gen`, and forgetting what passes + * there have taught it, as on a thread that has not collected yet. */ +size_t scr_cyc_backlog_threshold_at(unsigned gen) { return scr_cyc_backlog_threshold(gen); } +void scr_cyc_forget_backlog_at(unsigned gen) { + scr_cyc_backlog_shift[gen] = 0; + scr_cyc_backlog_judged[gen] = false; +} #endif size_t scr_cyc_collect_set(const ScrCycSet *set, unsigned gen) { diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index ce1e9ba85..d4ea39a93 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -1054,6 +1054,35 @@ static void check_rebuffer_past_mature(void) { * cycle-headered count has grown by SCR_CYC_NURSERY_ALLOCATIONS (4096 in * scr_cycle.c) since the last one, so the dead nodes left uncollected stay * within that much plus the ring in hand. */ +size_t scr_cyc_backlog_threshold_at(unsigned gen); +void scr_cyc_forget_backlog_at(unsigned gen); + +/* A heap that grows while its mature buffer stays empty: every release is + * of one old object, so the mature passes that growth brings have no + * candidates and only widen the mature growth fraction. Until a mature pass + * has had candidates, the backlog threshold widens with it, so a program + * that then reads the structure it built does not walk a quarter of it at + * once to find nothing. */ +static void check_unjudged_backlog_follows_growth(void) { + enum { LIVE = 65536 }; + Node **leaves = calloc(LIVE, sizeof(*leaves)); + if (!leaves) scr_trap("cycle test: out of memory\n"); + Node *anchor = make_leaf(); + age_to_old(anchor); + scr_cyc_forget_backlog_at(SCR_CYC_MATURE); + for (size_t i = 0; i < LIVE; i++) { + leaves[i] = make_leaf(); + read_live(anchor); + } + check(scr_cyc_backlog_threshold_at(SCR_CYC_MATURE) > scr_cyc_live / 4, + "an unjudged mature backlog stayed at a quarter of the live heap"); + drop_leaves(leaves, LIVE); + anchor->rc--; + scr_cyc_on_dead(anchor); + node_free(anchor); + free(leaves); +} + static void check_allocation_growth_runs_passes(void) { enum { RINGS = 80, NODES_PER_RING = 64, ALLOCATION_WINDOW = 4096 }; scr_collect_cycles(); @@ -1364,6 +1393,7 @@ int main(void) { check_publication_takes_candidates(); check_limited_pass(); check_large_old_buffer_with_nursery_candidates(); + check_unjudged_backlog_follows_growth(); printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n", configured_nursery_threshold(), configured_growth_cap()); return 0; From 95bdd7305cc4e99b29baf6e549849a0326e79df0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:22:51 -0500 Subject: [PATCH 60/85] Unroll the MD5 block function scr_md5_block ran its 64 steps as one loop that chose the round function, message index and rotation per step, and the update path called it once per block. scr_md5_blocks unrolls the steps with the message word, constant and rotation inlined, processes every whole block of an update in one call, and keeps each step's critical path to the round function of b, an add, the rotate and the add of b: a + m + K is summed first, G is written as (b & d) + (c & ~d) so that its c & ~d half joins that sum, and an empty asm statement keeps the compiler from moving the constant's add after the round function. MD5, HMAC-MD5 and md5-sha1 use it. Corpus 5775 compares MD5, md5-sha1 and HMAC-MD5 digests over every length from 0 to 520 bytes in five update sizes and over 1 MiB with Node; tests/harness/corpus-costs/ records its plain and sanitized costs. --- .../tests/corpus/5775-md5-lengths.ts.json | 6 + packages/runtime/src/scr_lib.c | 153 +++++++++++++----- tests/corpus/5775-md5-lengths.ts | 34 ++++ .../corpus-costs/5775-md5-lengths.ts.json | 1 + 4 files changed, 151 insertions(+), 43 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5775-md5-lengths.ts.json create mode 100644 tests/corpus/5775-md5-lengths.ts create mode 100644 tests/harness/corpus-costs/5775-md5-lengths.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5775-md5-lengths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5775-md5-lengths.ts.json new file mode 100644 index 000000000..84c394e4a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5775-md5-lengths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5775-md5-lengths.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 930d44818..ce7de9654 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -7263,45 +7263,112 @@ static ScrStr *scr_digest_encode(const unsigned char *d, size_t n, int enc) { /* ── MD5 (RFC 1321) ───────────────────────────────────────────────── */ -static const uint32_t scr_md5_k[64] = { - 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, - 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, - 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, - 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, - 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, - 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, - 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, - 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, - 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, - 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, - 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391}; -static const unsigned char scr_md5_r[64] = { - 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, - 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, - 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, - 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21}; +/* The 64 steps are unrolled with their message word, constant and + * rotation inlined. a + m + K does not depend on the previous step, so + * each step's critical path is the round function of b, an add, the + * rotate and the add of b. G is written as (b & d) + (c & ~d) (the two + * terms share no bits) so that its c & ~d half joins that early sum. The + * empty asm statement keeps the compiler from moving the constant's add + * after the round function, which puts it back on the critical path. */ +#if (defined(__GNUC__) || defined(__clang__)) && !defined(__wasm__) +#define SCR_MD5_PIN(v) __asm__("" : "+r"(v)) +#else +#define SCR_MD5_PIN(v) ((void)0) +#endif +#define SCR_MD5_F(b, c, d) ((d) ^ ((b) & ((c) ^ (d)))) +#define SCR_MD5_H(b, c, d) ((b) ^ (c) ^ (d)) +#define SCR_MD5_I(b, c, d) ((c) ^ ((b) | ~(d))) +#define SCR_MD5_STEP(f, a, b, c, d, xk, s) \ + do { \ + (a) += (xk); \ + SCR_MD5_PIN(a); \ + (a) += f((b), (c), (d)); \ + (a) = ((a) << (s) | (a) >> (32 - (s))) + (b); \ + } while (0) +#define SCR_MD5_GSTEP(a, b, c, d, xk, s) \ + do { \ + (a) += ((c) & ~(d)) + (xk); \ + SCR_MD5_PIN(a); \ + (a) += (b) & (d); \ + (a) = ((a) << (s) | (a) >> (32 - (s))) + (b); \ + } while (0) -SCR_DIGEST_ALIGNED static void scr_md5_block(uint32_t h[4], const unsigned char *p) { - uint32_t m[16]; - for (int i = 0; i < 16; i++) { - m[i] = (uint32_t)p[i * 4] | ((uint32_t)p[i * 4 + 1] << 8) | - ((uint32_t)p[i * 4 + 2] << 16) | ((uint32_t)p[i * 4 + 3] << 24); - } +SCR_DIGEST_ALIGNED static void scr_md5_blocks(uint32_t h[4], const unsigned char *p, size_t blocks) { uint32_t a = h[0], b = h[1], c = h[2], d = h[3]; - for (int i = 0; i < 64; i++) { - uint32_t f, g; - if (i < 16) { f = (b & c) | (~b & d); g = (uint32_t)i; } - else if (i < 32) { f = (d & b) | (~d & c); g = (5u * i + 1) & 15; } - else if (i < 48) { f = b ^ c ^ d; g = (3u * i + 5) & 15; } - else { f = c ^ (b | ~d); g = (7u * i) & 15; } - uint32_t t = d; - d = c; - c = b; - uint32_t x = a + f + scr_md5_k[i] + m[g]; - b = b + ((x << scr_md5_r[i]) | (x >> (32 - scr_md5_r[i]))); - a = t; + for (; blocks != 0; blocks--, p += 64) { + uint32_t m[16]; + for (int i = 0; i < 16; i++) { + m[i] = (uint32_t)p[i * 4] | ((uint32_t)p[i * 4 + 1] << 8) | + ((uint32_t)p[i * 4 + 2] << 16) | ((uint32_t)p[i * 4 + 3] << 24); + } + uint32_t a0 = a, b0 = b, c0 = c, d0 = d; + SCR_MD5_STEP(SCR_MD5_F, a, b, c, d, m[0] + 0xd76aa478u, 7); + SCR_MD5_STEP(SCR_MD5_F, d, a, b, c, m[1] + 0xe8c7b756u, 12); + SCR_MD5_STEP(SCR_MD5_F, c, d, a, b, m[2] + 0x242070dbu, 17); + SCR_MD5_STEP(SCR_MD5_F, b, c, d, a, m[3] + 0xc1bdceeeu, 22); + SCR_MD5_STEP(SCR_MD5_F, a, b, c, d, m[4] + 0xf57c0fafu, 7); + SCR_MD5_STEP(SCR_MD5_F, d, a, b, c, m[5] + 0x4787c62au, 12); + SCR_MD5_STEP(SCR_MD5_F, c, d, a, b, m[6] + 0xa8304613u, 17); + SCR_MD5_STEP(SCR_MD5_F, b, c, d, a, m[7] + 0xfd469501u, 22); + SCR_MD5_STEP(SCR_MD5_F, a, b, c, d, m[8] + 0x698098d8u, 7); + SCR_MD5_STEP(SCR_MD5_F, d, a, b, c, m[9] + 0x8b44f7afu, 12); + SCR_MD5_STEP(SCR_MD5_F, c, d, a, b, m[10] + 0xffff5bb1u, 17); + SCR_MD5_STEP(SCR_MD5_F, b, c, d, a, m[11] + 0x895cd7beu, 22); + SCR_MD5_STEP(SCR_MD5_F, a, b, c, d, m[12] + 0x6b901122u, 7); + SCR_MD5_STEP(SCR_MD5_F, d, a, b, c, m[13] + 0xfd987193u, 12); + SCR_MD5_STEP(SCR_MD5_F, c, d, a, b, m[14] + 0xa679438eu, 17); + SCR_MD5_STEP(SCR_MD5_F, b, c, d, a, m[15] + 0x49b40821u, 22); + SCR_MD5_GSTEP(a, b, c, d, m[1] + 0xf61e2562u, 5); + SCR_MD5_GSTEP(d, a, b, c, m[6] + 0xc040b340u, 9); + SCR_MD5_GSTEP(c, d, a, b, m[11] + 0x265e5a51u, 14); + SCR_MD5_GSTEP(b, c, d, a, m[0] + 0xe9b6c7aau, 20); + SCR_MD5_GSTEP(a, b, c, d, m[5] + 0xd62f105du, 5); + SCR_MD5_GSTEP(d, a, b, c, m[10] + 0x02441453u, 9); + SCR_MD5_GSTEP(c, d, a, b, m[15] + 0xd8a1e681u, 14); + SCR_MD5_GSTEP(b, c, d, a, m[4] + 0xe7d3fbc8u, 20); + SCR_MD5_GSTEP(a, b, c, d, m[9] + 0x21e1cde6u, 5); + SCR_MD5_GSTEP(d, a, b, c, m[14] + 0xc33707d6u, 9); + SCR_MD5_GSTEP(c, d, a, b, m[3] + 0xf4d50d87u, 14); + SCR_MD5_GSTEP(b, c, d, a, m[8] + 0x455a14edu, 20); + SCR_MD5_GSTEP(a, b, c, d, m[13] + 0xa9e3e905u, 5); + SCR_MD5_GSTEP(d, a, b, c, m[2] + 0xfcefa3f8u, 9); + SCR_MD5_GSTEP(c, d, a, b, m[7] + 0x676f02d9u, 14); + SCR_MD5_GSTEP(b, c, d, a, m[12] + 0x8d2a4c8au, 20); + SCR_MD5_STEP(SCR_MD5_H, a, b, c, d, m[5] + 0xfffa3942u, 4); + SCR_MD5_STEP(SCR_MD5_H, d, a, b, c, m[8] + 0x8771f681u, 11); + SCR_MD5_STEP(SCR_MD5_H, c, d, a, b, m[11] + 0x6d9d6122u, 16); + SCR_MD5_STEP(SCR_MD5_H, b, c, d, a, m[14] + 0xfde5380cu, 23); + SCR_MD5_STEP(SCR_MD5_H, a, b, c, d, m[1] + 0xa4beea44u, 4); + SCR_MD5_STEP(SCR_MD5_H, d, a, b, c, m[4] + 0x4bdecfa9u, 11); + SCR_MD5_STEP(SCR_MD5_H, c, d, a, b, m[7] + 0xf6bb4b60u, 16); + SCR_MD5_STEP(SCR_MD5_H, b, c, d, a, m[10] + 0xbebfbc70u, 23); + SCR_MD5_STEP(SCR_MD5_H, a, b, c, d, m[13] + 0x289b7ec6u, 4); + SCR_MD5_STEP(SCR_MD5_H, d, a, b, c, m[0] + 0xeaa127fau, 11); + SCR_MD5_STEP(SCR_MD5_H, c, d, a, b, m[3] + 0xd4ef3085u, 16); + SCR_MD5_STEP(SCR_MD5_H, b, c, d, a, m[6] + 0x04881d05u, 23); + SCR_MD5_STEP(SCR_MD5_H, a, b, c, d, m[9] + 0xd9d4d039u, 4); + SCR_MD5_STEP(SCR_MD5_H, d, a, b, c, m[12] + 0xe6db99e5u, 11); + SCR_MD5_STEP(SCR_MD5_H, c, d, a, b, m[15] + 0x1fa27cf8u, 16); + SCR_MD5_STEP(SCR_MD5_H, b, c, d, a, m[2] + 0xc4ac5665u, 23); + SCR_MD5_STEP(SCR_MD5_I, a, b, c, d, m[0] + 0xf4292244u, 6); + SCR_MD5_STEP(SCR_MD5_I, d, a, b, c, m[7] + 0x432aff97u, 10); + SCR_MD5_STEP(SCR_MD5_I, c, d, a, b, m[14] + 0xab9423a7u, 15); + SCR_MD5_STEP(SCR_MD5_I, b, c, d, a, m[5] + 0xfc93a039u, 21); + SCR_MD5_STEP(SCR_MD5_I, a, b, c, d, m[12] + 0x655b59c3u, 6); + SCR_MD5_STEP(SCR_MD5_I, d, a, b, c, m[3] + 0x8f0ccc92u, 10); + SCR_MD5_STEP(SCR_MD5_I, c, d, a, b, m[10] + 0xffeff47du, 15); + SCR_MD5_STEP(SCR_MD5_I, b, c, d, a, m[1] + 0x85845dd1u, 21); + SCR_MD5_STEP(SCR_MD5_I, a, b, c, d, m[8] + 0x6fa87e4fu, 6); + SCR_MD5_STEP(SCR_MD5_I, d, a, b, c, m[15] + 0xfe2ce6e0u, 10); + SCR_MD5_STEP(SCR_MD5_I, c, d, a, b, m[6] + 0xa3014314u, 15); + SCR_MD5_STEP(SCR_MD5_I, b, c, d, a, m[13] + 0x4e0811a1u, 21); + SCR_MD5_STEP(SCR_MD5_I, a, b, c, d, m[4] + 0xf7537e82u, 6); + SCR_MD5_STEP(SCR_MD5_I, d, a, b, c, m[11] + 0xbd3af235u, 10); + SCR_MD5_STEP(SCR_MD5_I, c, d, a, b, m[2] + 0x2ad7d2bbu, 15); + SCR_MD5_STEP(SCR_MD5_I, b, c, d, a, m[9] + 0xeb86d391u, 21); + a += a0; b += b0; c += c0; d += d0; } - h[0] += a; h[1] += b; h[2] += c; h[3] += d; + h[0] = a; h[1] = b; h[2] = c; h[3] = d; } typedef struct ScrMd5Ctx { @@ -7329,14 +7396,15 @@ static void scr_md5_update(ScrMd5Ctx *ctx, const unsigned char *data, size_t len data += take; len -= take; if (ctx->tail_len == 64) { - scr_md5_block(ctx->h, ctx->tail); + scr_md5_blocks(ctx->h, ctx->tail, 1); ctx->tail_len = 0; } } - while (len >= 64) { - scr_md5_block(ctx->h, data); - data += 64; - len -= 64; + if (len >= 64) { + size_t blocks = len / 64; + scr_md5_blocks(ctx->h, data, blocks); + data += blocks * 64; + len -= blocks * 64; } if (len != 0) { memcpy(ctx->tail, data, len); @@ -7354,8 +7422,7 @@ static size_t scr_md5_final(const ScrMd5Ctx *source, unsigned char out[32]) { memset(tail + rem + 1, 0, pad - rem - 1 - 8); uint64_t bits = ctx.bytes * 8; for (int b = 0; b < 8; b++) tail[pad - 8 + b] = (unsigned char)(bits >> (8 * b)); - scr_md5_block(ctx.h, tail); - if (pad == 128) scr_md5_block(ctx.h, tail + 64); + scr_md5_blocks(ctx.h, tail, pad / 64); for (int j = 0; j < 4; j++) { for (int b = 0; b < 4; b++) out[j * 4 + b] = (unsigned char)(ctx.h[j] >> (8 * b)); } diff --git a/tests/corpus/5775-md5-lengths.ts b/tests/corpus/5775-md5-lengths.ts new file mode 100644 index 000000000..957491568 --- /dev/null +++ b/tests/corpus/5775-md5-lengths.ts @@ -0,0 +1,34 @@ +// MD5 and md5-sha1 over every length from 0 to 520 bytes, fed whole and +// in 1-, 7-, 64- and 100-byte updates, then over 1 MiB, and HMAC-MD5 +// over a few lengths. Each length's digests are folded into one running +// SHA-512 so the output stays short. +import { createHash, createHmac } from "node:crypto"; + +const data = Buffer.alloc(1 << 20); +let x = 0x9e3779b9; +for (let i = 0; i < data.length; i++) { + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + data[i] = x & 0xff; +} + +function digest(algorithm: string, input: Buffer, chunk: number): Buffer { + const hash = createHash(algorithm); + if (chunk === 0) hash.update(input); + else for (let at = 0; at < input.length; at += chunk) hash.update(input.subarray(at, at + chunk)); + return hash.digest(); +} + +for (const algorithm of ["md5", "md5-sha1"]) { + for (const chunk of [0, 1, 7, 64, 100]) { + const fold = createHash("sha512"); + for (let length = 0; length <= 520; length++) fold.update(digest(algorithm, data.subarray(0, length), chunk)); + console.log(algorithm, "chunk", chunk, fold.digest("hex").slice(0, 32)); + } + console.log(algorithm, "1 MiB", digest(algorithm, data, 0).toString("hex").slice(0, 32), digest(algorithm, data, 4096).toString("hex").slice(0, 32)); +} +console.log("abc", createHash("md5").update("abc").digest("hex")); +for (const length of [0, 1, 55, 56, 63, 64, 65, 119, 120, 1000]) { + console.log("hmac", length, createHmac("md5", "key").update(data.subarray(0, length)).digest("hex").slice(0, 32)); +} diff --git a/tests/harness/corpus-costs/5775-md5-lengths.ts.json b/tests/harness/corpus-costs/5775-md5-lengths.ts.json new file mode 100644 index 000000000..70f936cec --- /dev/null +++ b/tests/harness/corpus-costs/5775-md5-lengths.ts.json @@ -0,0 +1 @@ +{"plain":2850,"san":3660} From 73bd0b53c396c485785f6ad3db0b7caeba76f16e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 21:23:54 -0700 Subject: [PATCH 61/85] Pass rest-parameter functions into slots with fixed parameters A typed rest-parameter function passed where a function with fixed parameters is expected (`log(...m: string[])` into `(m: string) => void`) was refused. The function adapter now packs the slot's remaining arguments into the rest array, and the value check admits the function wherever that adapter fits. A nested function declaration with a typed rest parameter becomes a value with its own typed-rest signature, converted at each use. Co-Authored-By: Claude Opus 5.5 --- .../language-features/function-values.mdx | 2 +- .../src/frontend/lowering/call-signatures.ts | 26 +++++++ .../lowering/coercions/function-adapters.ts | 72 +++++++++++++++++++ .../6445-rest-function-fixed-slot.ts.json | 6 ++ ...6-rest-function-nested-declaration.ts.json | 6 ++ tests/corpus/6445-rest-function-fixed-slot.ts | 28 ++++++++ .../6446-rest-function-nested-declaration.ts | 26 +++++++ 7 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6445-rest-function-fixed-slot.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6446-rest-function-nested-declaration.ts.json create mode 100644 tests/corpus/6445-rest-function-fixed-slot.ts create mode 100644 tests/corpus/6446-rest-function-nested-declaration.ts diff --git a/docs/content/limitations/language-features/function-values.mdx b/docs/content/limitations/language-features/function-values.mdx index 88422c406..75229ff95 100644 --- a/docs/content/limitations/language-features/function-values.mdx +++ b/docs/content/limitations/language-features/function-values.mdx @@ -1,4 +1,4 @@ - **Function values:** Immutable aliases of overloaded program functions retain per-call overload resolution and identity. A function stored in a slot with a different signature remains the same function for equality, array searches, `length`, and own properties. - Typed rest-parameter functions can be stored and called indirectly. + Typed rest-parameter functions can be stored and called indirectly, including through a slot with fixed parameters, where the slot's arguments arrive in the rest array. Builtin function values have API-specific restrictions; see [Builtin function values](#builtin-function-values). diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 6db1735db..96fbd106b 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -27,6 +27,7 @@ import { isStreamUndefCallExpr } from "./lower-server.js"; import { nsPathPrefix } from "./lower-namespaces.js"; import { declSymbolOf } from "./lower-modules.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; +import { restPackingAdapterFits } from "./coercions/function-adapters.js"; import { implicitMonoFile, implicitAnyParamSymbolsOf } from "./generic-functions.js"; /** How a parameter participates in CALL-SITE COMPLETION (the frontend @@ -522,6 +523,21 @@ export function requireExactArityValue( // optional/defaulted params spell their `T | undefined` slots (mapType's // completed-signature contract), so omitted trailing args complete with // the undefined arm like any direct call. + // A nested function declaration whose only non-required parameter is a + // typed rest: its value carries the typed-rest ABI its own type spells, + // and every use converts it at its slot (same signature, or the rest + // packing adapter for fixed parameters). + if ( + contextual === null && + ts.isFunctionDeclaration(blame) && + blame.name !== undefined && + funcType.kind === "func" && + funcType.restAbi === "typed" && + shapes.every((s) => s.mode === "required" || s.mode === "rest") + ) { + const own = lowerer.mapTypeOf(lowerer.typeOf(blame.name)); + if (own && typeEquals(own, funcType)) return; + } const target = contextual ? lowerer.checker.getContextualType(contextual) : undefined; const mapped = target ? lowerer.mapTypeOf(target) @@ -582,6 +598,16 @@ export function requireExactArityValue( ) { return; } + // A typed-rest function in a slot with fixed parameters (`log(...m: + // string[])` into `(m: string) => void`): the slot coercion's packing + // adapter collects the slot's arguments into the rest array. + if ( + mappedFn?.kind === "func" && + funcType.kind === "func" && + restPackingAdapterFits(lowerer, funcType, mappedFn) + ) { + return; + } // An uncontextualized npm-static implementation function can still // have a declaration-backed completed ABI: the package's .d.ts // projection supplies the body-facing parameter/return types while diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index b99c24a15..36908e6f9 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -130,12 +130,84 @@ export function cleanFuncAdaptable( /** Adapt a function value to a destination signature, forwarding and * converting its arguments and result. Unsupported parameter/result pairs * produce checked traps when called; rest signatures remain refused. */ +/** Whether a typed-rest function (`(...m: T[]) => R`) can serve a slot + * with fixed parameters (`(m: T) => void`): the slot's parameters convert + * to the function's fixed parameters, then to the rest element type. */ +export function restPackingAdapterFits( + lowerer: Lowerer, + fromT: IrType & { kind: "func" }, + toT: IrType & { kind: "func" }, +): boolean { + if (fromT.rest !== true || fromT.restAbi !== "typed" || toT.rest === true) return false; + const fixed = fromT.params.length - 1; + const restT = fromT.params[fixed]; + if (restT?.kind !== "array" || toT.params.length < fixed) return false; + for (let i = 0; i < toT.params.length; i++) { + if (!lowerer.coercibleValue(toT.params[i]!, i < fixed ? fromT.params[i]! : restT.elem)) + return false; + } + return toT.ret.kind === "void" || lowerer.coercibleValue(fromT.ret, toT.ret); +} + +/** The adapter for restPackingAdapterFits: forwards the fixed arguments and + * packs the remaining ones into the rest array, as a JavaScript call of the + * function with those arguments does. */ +function restPackingAdapter( + lowerer: Lowerer, + fromT: IrType & { kind: "func" }, + toT: IrType & { kind: "func" }, + loc: SrcLoc, +): string { + const key = `fnrestadapt:${typeKey(fromT)}:${typeKey(toT)}`; + const existing = lowerer.coercions.retags.get(key); + if (existing) return existing; + const name = `%fn.adapt.${lowerer.coercions.retags.size}`; + lowerer.coercions.retags.set(key, name); + lowerer.freshClosureAdapters.add(name); + const fixed = fromT.params.length - 1; + const restT = fromT.params[fixed] as IrType & { kind: "array" }; + const params: IrParam[] = toT.params.map((t, i) => ({ + localId: `a.${i}`, + name: `a${i}`, + type: t, + })); + const arg = (i: number, want: IrType): IrExpr => + lowerer.coerceToExpected( + { kind: "varRef", localId: `a.${i}`, type: toT.params[i]!, loc }, + want, + ); + const args: IrExpr[] = []; + for (let i = 0; i < fixed; i++) args.push(arg(i, fromT.params[i]!)); + const packed: IrExpr[] = []; + for (let i = fixed; i < toT.params.length; i++) packed.push(arg(i, restT.elem)); + args.push({ kind: "arrayLit", elems: packed, type: restT, loc }); + const call: IrExpr = { + kind: "callValue", + callee: { kind: "varRef", localId: "f.0", type: fromT, loc }, + receiver: { kind: "libCall", fn: "dyn.this", args: [], type: DYN, loc }, + args, + type: fromT.ret, + loc, + }; + const body: IrStmt[] = + toT.ret.kind === "void" + ? [ + { kind: "exprStmt", expr: call, loc }, + { kind: "return", value: null, loc }, + ] + : [{ kind: "return", value: lowerer.coerceToExpected(call, toT.ret), loc }]; + lowerer.liftedFns.push(...buildFunctionAdapter(name, fromT, toT, params, [], body, loc)); + return name; +} + export function funcCoerceAdapter( lowerer: Lowerer, fromT: IrType & { kind: "func" }, toT: IrType & { kind: "func" }, loc: SrcLoc, ): string | null { + if (restPackingAdapterFits(lowerer, fromT, toT)) + return restPackingAdapter(lowerer, fromT, toT, loc); if (fromT.rest === true || toT.rest === true) return null; if (fromT.params.length > toT.params.length) return null; // Piece dispositions beyond coercibleValue, all CHECKER-APPROVED diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6445-rest-function-fixed-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6445-rest-function-fixed-slot.ts.json new file mode 100644 index 000000000..b39c2fa38 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6445-rest-function-fixed-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6445-rest-function-fixed-slot.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6446-rest-function-nested-declaration.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6446-rest-function-nested-declaration.ts.json new file mode 100644 index 000000000..41c034395 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6446-rest-function-nested-declaration.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6446-rest-function-nested-declaration.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6445-rest-function-fixed-slot.ts b/tests/corpus/6445-rest-function-fixed-slot.ts new file mode 100644 index 000000000..6c7dbeada --- /dev/null +++ b/tests/corpus/6445-rest-function-fixed-slot.ts @@ -0,0 +1,28 @@ +// A rest-parameter function in a slot with fixed parameters: the slot's +// arguments arrive in the rest array, as in a JavaScript call. +function log(...messages: string[]): void { + console.log(`log(${messages.length}):`, messages.join(" | ")); +} +function tagged(tag: string, ...values: number[]): number { + console.log(tag, values.join(",")); + return values.length; +} + +class Line { + private readonly sink: (m: string) => void; + constructor(sink: (m: string) => void) { + this.sink = sink; + } + emit(m: string): void { + this.sink(m); + } +} + +new Line(log).emit("one"); +const pair: (a: string, b: string) => void = log; +pair("x", "y"); +const none: () => void = log; +none(); +const count: (tag: string, a: number, b: number) => number = tagged; +console.log("count", count("t", 1, 2)); +["a", "b"].forEach((m) => new Line(log).emit(m)); diff --git a/tests/corpus/6446-rest-function-nested-declaration.ts b/tests/corpus/6446-rest-function-nested-declaration.ts new file mode 100644 index 000000000..1b2d7aa17 --- /dev/null +++ b/tests/corpus/6446-rest-function-nested-declaration.ts @@ -0,0 +1,26 @@ +// Nested rest-parameter function declarations passed to a fixed slot. +class Line { + private readonly sink: (m: string) => void; + constructor(sink: (m: string) => void) { + this.sink = sink; + } + emit(m: string): void { + this.sink(m); + } +} +interface Logger { + line: () => Line; + log: (...messages: string[]) => void; +} +function makeLogger(prefix: string): Logger { + return { + line: () => new Line(log), + log, + }; + function log(...messages: string[]) { + console.log(prefix, messages.join(" ")); + } +} +const logger = makeLogger(">"); +logger.log("a", "b"); +logger.line().emit("c"); From b00dac886ee7f7ab8b2482894a766229a3c74106 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:24:04 -0500 Subject: [PATCH 62/85] Copy repeated childless IR nodes one kind at a time unshareNodes copies a repeated childless node through reparseExpr and reparseStmt, which stringify the node and parse the text and so copy its type and location objects as well; on the three.js nodes fixture that takes about 6 s. Spreading the whole IrExpr or IrStmt union instead does not compile in the bootstrap seed build: the optional-read analysis lets the callback of `fn.body.map(stmt)` receive undefined, and a union record copy of a source that may be undefined is not supported (SC1090). copyChildlessExpr and copyChildlessStmt switch over the node kind and spread the narrowed node in each case, which is a plain record copy of that kind. The cases are the kinds mapExprChildren and mapStmtChildren return unchanged; any other kind keeps the encoding's copy. Each copy is a new node object, which is what analyses key their facts by, and shares the type and location objects, as copies of nodes with children already do. The emitted IR is unchanged for every corpus program. unshareNodes takes about 1.8 s on the three.js nodes fixture instead of 6.5 s, and compiling it to LLVM text takes 115 s instead of 123 s (median of five interleaved runs). --- packages/compiler/src/ir/unshare.ts | 70 +++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/ir/unshare.ts b/packages/compiler/src/ir/unshare.ts index 36dd6176f..a83d52a5f 100644 --- a/packages/compiler/src/ir/unshare.ts +++ b/packages/compiler/src/ir/unshare.ts @@ -42,14 +42,15 @@ export function unshareNodes(mod: IrModule): IrModule { } if (affected.size === 0) return mod; // Rebuilding a function copies every node that has children; a repeated - // childless node reads back as a fresh object at each of its sites there. + // childless node gets its own shallow copy at each of its sites there. + // Both kinds of copy keep sharing the type and location objects. const expr = (node: IrExpr): IrExpr => { const copy = mapExprChildren(node, expr, stmt); - return copy !== node || !repeated.has(node) ? copy : reparseExpr(node); + return copy !== node || !repeated.has(node) ? copy : copyChildlessExpr(node); }; const stmt = (node: IrStmt): IrStmt => { const copy = mapStmtChildren(node, expr, stmt); - return copy !== node || !repeated.has(node) ? copy : reparseStmt(node); + return copy !== node || !repeated.has(node) ? copy : copyChildlessStmt(node); }; return { ...mod, @@ -58,3 +59,66 @@ export function unshareNodes(mod: IrModule): IrModule { ), }; } + +/** A shallow copy of a node that mapExprChildren returns unchanged. Each + * case spreads one node kind, so the copy is a plain record copy of that + * kind. Any other kind is copied through the encoding. */ +function copyChildlessExpr(node: IrExpr): IrExpr { + switch (node.kind) { + case "varRef": + return { ...node }; + case "strLit": + return { ...node }; + case "numLit": + return { ...node }; + case "boolLit": + return { ...node }; + case "unitLit": + return { ...node }; + case "closure": + return { ...node }; + case "moduleNsRef": + return { ...node }; + case "incDec": + return { ...node }; + case "chainRecv": + return { ...node }; + case "regexLit": + return { ...node }; + case "templateStrings": + return { ...node }; + case "setNew": + return { ...node }; + case "mapNew": + return { ...node }; + case "dynObjLit": + return { ...node }; + case "selfRef": + return { ...node }; + case "promiseWithResolvers": + return { ...node }; + case "classRef": + return { ...node }; + case "fieldAbsent": + return { ...node }; + default: + return reparseExpr(node); + } +} + +/** A shallow copy of a statement that mapStmtChildren returns unchanged, + * one case per kind as in copyChildlessExpr. */ +function copyChildlessStmt(node: IrStmt): IrStmt { + switch (node.kind) { + case "break": + return { ...node }; + case "continue": + return { ...node }; + case "runtimeFence": + return { ...node }; + case "rethrow": + return { ...node }; + default: + return reparseStmt(node); + } +} From 3d00b5b8370e5cf87aa3ca700c6c8717c496e9ce Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:24:56 -0500 Subject: [PATCH 63/85] Size encoded messages to their nodes and wake a channel's owner once per batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message encoder started its node array at 16 nodes of 184 bytes and kept the array, with its unused capacity, until the message was received. A port that queued many messages held about 3 KB of zeroed memory per message: 200,000 queued numbers took about 590 MB, and the page faults made postMessage cost about 1.3 µs. The node array now starts at 4 nodes and is trimmed to the message's node count when encoding ends. scr_channel_post posted a wake event, and with it a pipe write and a condition broadcast, to the receiving thread's mailbox for every message. The owner's loop counts a port with queued messages as ready, so only a message that finds the queue empty needs to wake it. bench/channel-messages, added here, posts 200,000 messages (numbers and small objects) on a MessageChannel before the port delivers any. --- benchmarks/runtime/README.md | 1 + benchmarks/runtime/workloads.json | 7 +++++++ .../runtime/workloads/channel-messages.ts | 21 +++++++++++++++++++ packages/runtime/src/scr_mailbox.c | 7 +++++-- packages/runtime/src/scr_message.c | 11 +++++++++- scripts/zero-cost-compare.mjs | 7 ++++--- 6 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 benchmarks/runtime/workloads/channel-messages.ts diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 6fe670e1e..f8552b003 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -18,6 +18,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread | +| `channel-messages` | cpu | 200,000 MessageChannel messages in one thread, half numbers and half small objects, all posted before the port delivers any | | `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs | | `value-objects` | cpu | small immutable records and class instances: vector helpers, method chains, particle updates | | `value-records` | cpu | records that hold strings, spread state updates and destructured tuples: token helpers, an immutable reducer, tuple returns | diff --git a/benchmarks/runtime/workloads.json b/benchmarks/runtime/workloads.json index 76d27fbc2..9a3f1bc2f 100644 --- a/benchmarks/runtime/workloads.json +++ b/benchmarks/runtime/workloads.json @@ -147,6 +147,13 @@ "20" ] }, + { + "name": "channel-messages", + "entry": "workloads/channel-messages.ts", + "args": [ + "200000" + ] + }, { "name": "atomics-contended", "entry": "workloads/atomics-contended.ts", diff --git a/benchmarks/runtime/workloads/channel-messages.ts b/benchmarks/runtime/workloads/channel-messages.ts new file mode 100644 index 000000000..5d11adee4 --- /dev/null +++ b/benchmarks/runtime/workloads/channel-messages.ts @@ -0,0 +1,21 @@ +// MessageChannel messages within one thread: the program posts the given +// number of messages, half of them numbers and half small objects, before +// the port delivers any, so all of them are queued at once. Argument: the +// number of messages. +import { MessageChannel } from "node:worker_threads"; + +const count = Number(process.argv[2] ?? "200000"); +const { port1, port2 } = new MessageChannel(); +let received = 0; +let sum = 0; +port1.on("message", (value: number | { id: number; name: string }) => { + sum += typeof value === "number" ? value : value.id + value.name.length; + if (++received === count) { + console.log(`${count} messages, sum ${sum}`); + port1.close(); + } +}); +for (let i = 0; i < count; i++) { + if (i % 2 === 0) port2.postMessage(i); + else port2.postMessage({ id: i, name: "item" }); +} diff --git a/packages/runtime/src/scr_mailbox.c b/packages/runtime/src/scr_mailbox.c index f983bd289..39e36f4e5 100644 --- a/packages/runtime/src/scr_mailbox.c +++ b/packages/runtime/src/scr_mailbox.c @@ -349,7 +349,9 @@ void scr_channel_set_owner(ScrChannel *channel, int side, ScrMailbox *owner, uns } /* postMessage from `side`: the message (consumed) queues on the other end - * unless either end has closed. */ + * unless either end has closed. Only a message that finds the end's queue + * empty wakes its owner: the owner's loop counts an end with queued + * messages as ready, so it does not wait while the queue is non-empty. */ bool scr_channel_post(ScrChannel *channel, int side, ScrMessage *message, unsigned message_kind, unsigned wake_kind) { ScrMailEvent *event = calloc(1, sizeof(*event)); @@ -363,10 +365,11 @@ bool scr_channel_post(ScrChannel *channel, int side, ScrMessage *message, unsign scr_mail_event_free(event); return false; } + bool first = peer->count == 0; *peer->tail = event; peer->tail = &event->next; peer->count++; - ScrMailbox *wake = peer->owner ? scr_mailbox_retain(peer->owner) : NULL; + ScrMailbox *wake = first && peer->owner ? scr_mailbox_retain(peer->owner) : NULL; scr_channel_unlock(channel); scr_channel_wake(wake, wake_kind); return true; diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 7c969a2e3..1d01e5573 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -119,7 +119,7 @@ static void *scr_message_alloc(size_t count, size_t size) { static size_t scr_message_grow(size_t capacity) { if (capacity > SIZE_MAX / 2) scr_trap("scriptc: message too large\n"); - return capacity ? capacity * 2 : 16; + return capacity ? capacity * 2 : 4; } static unsigned char *scr_message_copy(const void *data, size_t length) { @@ -672,6 +672,15 @@ done: free(encoder.identities); free(encoder.stack); if (scr_exc_pending()) { scr_message_free(message); return NULL; } + /* A queued message keeps its nodes until it is received: drop the + * growth slack. */ + if (message->count && message->count < message->capacity) { + ScrMessageNode *nodes = realloc(message->nodes, message->count * sizeof(*nodes)); + if (nodes) { + message->nodes = nodes; + message->capacity = message->count; + } + } return message; } diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 871754258..426be3143 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -391,9 +391,9 @@ export function dynamicTierSymbols(names) { /* Bench workloads whose release binaries link dynamic-tier symbols on * b14377864: JSON.parse trees, worker messages, regex exec metadata * (two-tier §3.3 L5), and a shared scr_dyn_call/scr_dyn_define_property core - * not yet traced to its typed root. bench/thread-slots, added later, links - * them through worker messages: node:worker_threads values are dyn objects. - * Every other bench workload is fully typed and must link none. The gate and + * not yet traced to its typed root. bench/thread-slots and + * bench/channel-messages, added later, link them through worker messages: + * node:worker_threads values are dyn objects. Every other bench workload is fully typed and must link none. The gate and * tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also * fails when a listed workload stops linking them, so remove a workload here * when a leak fix makes its binary clean. */ @@ -406,6 +406,7 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/atomics-contended", "bench/intern-table", "bench/thread-slots", + "bench/channel-messages", "bench/typed-array-scan-workers", "bench/log-summary", "bench/inventory-report", From c0048af88b0fb8d89dce2455cb375e1a92b52147 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:26:01 -0500 Subject: [PATCH 64/85] Box asserted class bindings into class-backed views `const node = stack.pop()!` keeps the value's undefined at run time, so the binding holds `Base | undefined`. Asserting it to a class-backed protocol view (`node as unknown as View`) reported SC1090, while the same cast of a plain instance boxed the instance. A union of class instances and undefined or null now boxes the same way: class arms give the live instance, and undefined stays undefined until a property read throws Node's TypeError. --- .../lowering/expressions/narrowing.ts | 18 ++++- .../6058-class-view-asserted-bindings.ts.json | 6 ++ .../6058-class-view-asserted-bindings.ts | 78 +++++++++++++++++++ .../6058-class-view-asserted-bindings.ts.json | 1 + 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6058-class-view-asserted-bindings.ts.json create mode 100644 tests/corpus/6058-class-view-asserted-bindings.ts create mode 100644 tests/harness/corpus-costs/6058-class-view-asserted-bindings.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 207000717..ca68d34da 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -1151,6 +1151,18 @@ export function withRuntimeOptionalNarrowed( * memory); * - dyn → anything else (closures, class instances, void) is rejected: * those types cannot be found inside a JSON dyn. */ +/** A class instance, or a union of class instances and unit arms (a + * runtime-optional binding such as `const node = stack.pop()!`). */ +function classInstanceOrAbsent(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "object") return true; + if (type.kind !== "union") return false; + const arms = lowerer.unions.get(type.unionId)?.arms ?? []; + return ( + arms.some((arm) => arm.kind === "object") && + arms.every((arm) => arm.kind === "object" || isUnitType(arm)) + ); +} + /** `e as T` and the old-style assertion `e` — one node shape (both * carry `.type` and `.expression`), one lowering. */ export function lowerAsExpression( @@ -1228,8 +1240,10 @@ export function lowerAsExpression( if (target?.kind === "dyn" && (targetTs0.flags & ts.TypeFlags.Object) !== 0) { // A class instance asserted to a class-backed protocol view (a type // class instances flow into): the same identity-keeping box an - // implicit flow into the view uses. - if (inner.type.kind === "object" && lowerer.classImplementedProtocol(targetTs0)) + // implicit flow into the view uses. A runtime-optional binding of a + // class (`const node = stack.pop()!`) boxes the same way, and its + // undefined arm stays undefined, as in Node. + if (classInstanceOrAbsent(lowerer, inner.type) && lowerer.classImplementedProtocol(targetTs0)) return lowerer.coerceInto(expr, inner, DYN); const sourceShape = inner.type.kind === "record" ? lowerer.shapes.get(inner.type.shapeId) : undefined; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6058-class-view-asserted-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6058-class-view-asserted-bindings.ts.json new file mode 100644 index 000000000..b2a73c8a3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6058-class-view-asserted-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6058-class-view-asserted-bindings.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6058-class-view-asserted-bindings.ts b/tests/corpus/6058-class-view-asserted-bindings.ts new file mode 100644 index 000000000..5014e3b4b --- /dev/null +++ b/tests/corpus/6058-class-view-asserted-bindings.ts @@ -0,0 +1,78 @@ +// A class instance held in a binding that asserts a possibly undefined +// value (`const node = stack.pop()!`) keeps that undefined at run time. +// Asserting the binding to a view of the class (`node as unknown as View`) +// gives the same live instance a direct cast does: getters run on each +// read, the view is the instance for identity, and an undefined value +// stays undefined until a property read throws Node's TypeError. +interface View { + readonly kind: number; + readonly label: string; +} + +abstract class Base { + reads = 0; + abstract get kind(): number; + get label(): string { + this.reads++; + return `node${this.kind}`; + } +} + +class Leaf extends Base { + private readonly value: number; + constructor(value: number) { + super(); + this.value = value; + } + get kind(): number { + return this.value; + } +} + +class Branch extends Base { + get kind(): number { + return 10; + } +} + +function walk(roots: Base[], cb: (node: View) => void): void { + while (roots.length > 0) { + const node = roots.pop()!; + cb(node as unknown as View); + } +} + +const asView = (node: Base): View => node as unknown as View; + +function walkWith(roots: Base[], cb: (node: View) => void): void { + while (roots.length > 0) { + const node = roots.pop()!; + cb(asView(node)); + } +} + +const seen: View[] = []; +let total = 0; +const leaf = new Leaf(2); +walk([new Leaf(1), leaf, new Branch()], (view) => { + total += view.kind; + seen.push(view); +}); +console.log(total, seen.map((view) => view.label).join(",")); +console.log(seen.includes(leaf as unknown as View), leaf.reads); + +const labels: string[] = []; +walkWith([new Branch(), new Leaf(3)], (view) => labels.push(`${view.kind}:${view.label}`)); +console.log(labels.join(" ")); + +function first(roots: Base[]): View { + const node = roots.pop()!; + return node as unknown as View; +} +const missing = first([]); +console.log(missing === undefined); +try { + console.log(missing.kind); +} catch (error) { + console.log(error instanceof TypeError, (error as Error).message); +} diff --git a/tests/harness/corpus-costs/6058-class-view-asserted-bindings.ts.json b/tests/harness/corpus-costs/6058-class-view-asserted-bindings.ts.json new file mode 100644 index 000000000..80e35e544 --- /dev/null +++ b/tests/harness/corpus-costs/6058-class-view-asserted-bindings.ts.json @@ -0,0 +1 @@ +{ "plain": 6139, "san": 10783 } From bc9ef21b90a6919fca14a62f5cc67b4b4147b7ea Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:28:21 -0500 Subject: [PATCH 65/85] Keep DataView's own behavior through unknown A DataView reached as a checked-dynamic value behaved like a Uint8Array over its bytes: it printed as `Uint8Array(2) [ 0, 0 ]`, its string and tag were the typed array's, `.length` and index reads answered from the bytes, and typed array methods ran on it. It now inspects as Node's `DataView { [byteLength], [byteOffset], [buffer] }`, answers "[object DataView]", has no length or indexed elements, and throws "is not a function" for typed array methods. --- packages/compiler/src/backend/llvm/dyn.ts | 10 ++--- .../corpus/6346-inspect-dyn-dataview.ts.json | 6 +++ .../corpus/6347-dataview-methods-js.cjs.json | 6 +++ packages/runtime/src/scr_dyn_invoke.c | 11 +++++ packages/runtime/src/scr_inspect.c | 36 ++++++++++++++++ packages/runtime/src/scr_json.c | 17 ++++++-- tests/corpus/6346-inspect-dyn-dataview.ts | 41 +++++++++++++++++++ tests/corpus/6347-dataview-methods-js.cjs | 25 +++++++++++ .../6346-inspect-dyn-dataview.ts.json | 1 + .../6347-dataview-methods-js.cjs.json | 1 + 10 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6346-inspect-dyn-dataview.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6347-dataview-methods-js.cjs.json create mode 100644 tests/corpus/6346-inspect-dyn-dataview.ts create mode 100644 tests/corpus/6347-dataview-methods-js.cjs create mode 100644 tests/harness/corpus-costs/6346-inspect-dyn-dataview.ts.json create mode 100644 tests/harness/corpus-costs/6347-dataview-methods-js.cjs.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index ae4f12fb8..86b4f5be8 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -3265,8 +3265,9 @@ export class LlDyn { } B.startBlock(labels.get(DYN_KIND.BYTES)!); { - // Buffer-flavored values coerce utf8; plain Uint8Array joins - // its elements. + // Buffer-flavored values coerce utf8; typed arrays join their + // elements and a DataView answers "[object DataView]" (the + // runtime's conversion). const bufp = B.tmp(); const bufRaw = B.tmp(); const isBuf = B.tmp(); @@ -3288,10 +3289,9 @@ export class LlDyn { B.line(`call void @scr_str_release(ptr ${txt})`); B.br(done); B.startBlock(lJoin); - const bts = this.payloadOf(B, "%d", "ptr"); - host.declare(`declare ptr @scr_bytes_join(ptr, ptr)`); + host.declare(`declare ptr @scr_dyn_to_string(ptr, ptr)`); const joined = B.tmp(); - B.line(`${joined} = call ptr @scr_bytes_join(ptr ${bts}, ptr ${host.internLiteral(",")})`); + B.line(`${joined} = call ptr @scr_dyn_to_string(ptr %d, ptr null)`); this.putScrStr(B, "%b", joined); B.line(`call void @scr_str_release(ptr ${joined})`); B.br(done); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6346-inspect-dyn-dataview.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6346-inspect-dyn-dataview.ts.json new file mode 100644 index 000000000..3e144e748 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6346-inspect-dyn-dataview.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6346-inspect-dyn-dataview.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6347-dataview-methods-js.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6347-dataview-methods-js.cjs.json new file mode 100644 index 000000000..91c408e7a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6347-dataview-methods-js.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6347-dataview-methods-js.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 8a1bfa728..90e8d0363 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -1558,6 +1558,17 @@ static ScrDyn *scr_dyn_invoke_impl( } return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); } + /* The typed array methods below are not DataView's; it inherits + * Object.prototype's. */ + if (dyn_name_is(method, "toString") || dyn_name_is(method, "toLocaleString")) { + ScrStr *tag = scr_str_new("[object DataView]", 17); + ScrDyn *out = scr_dyn_new_str(tag); + scr_str_release(tag); + return out; + } + if (dyn_name_is(method, "valueOf")) return scr_dyn_retain(recv); + dyn_throw_not_fn(what); + return NULL; } size_t blen = bytes->len; if (dyn_name_is(method, "at")) { diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index e38c7eb9b..3903cabeb 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -823,6 +823,41 @@ static ScrStr *scr_insp_array_buffer(const ScrBytes *h, const char *cls, double return out; } +/* DataView: formatValue's `DataView { [byteLength], [byteOffset], + * [buffer] }`, the buffer rendered one level deeper. `[DataView]` beyond + * depth. */ +static ScrStr *scr_insp_data_view(const ScrBytes *b, double recurse, double depth) { + if (recurse > depth) return scr_str_new("[DataView]", 10); + const ScrBytes *owner = b->backing ? b->backing : b; + char line[64]; + scr_insp_begin(recurse + 1); + int n = snprintf(line, sizeof line, "[byteLength]: %.0f", scr_bytes_byte_len(b)); + ScrStr *entry = scr_str_new(line, (size_t)n); + scr_insp_entry(entry, false); + scr_str_release(entry); + n = snprintf(line, sizeof line, "[byteOffset]: %.0f", scr_bytes_byte_offset(b)); + entry = scr_str_new(line, (size_t)n); + scr_insp_entry(entry, false); + scr_str_release(entry); + ScrStr *buffer = scr_insp_array_buffer(owner, owner->shared ? "SharedArrayBuffer" : "ArrayBuffer", + recurse + 1, depth); + InspBuf eb = {0}; + ib_cstr(&eb, "[buffer]: "); + ib_bytes(&eb, buffer->data, buffer->len); + scr_str_release(buffer); + entry = ib_take(&eb); + scr_insp_entry(entry, false); + scr_str_release(entry); + ScrStr *base = scr_str_new("", 0); + ScrStr *b0 = scr_str_new("DataView {", 10); + ScrStr *b1 = scr_str_new("}", 1); + ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, false, false); + scr_str_release(base); + scr_str_release(b0); + scr_str_release(b1); + return out; +} + static ScrStr *scr_insp_dyn_error(ScrDyn *d, double recurse, double depth); /* A runtime error through its node: the cached one, or one built from its @@ -1542,6 +1577,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } case SCR_DYN_BYTES: { ScrBytes *b = d->v.bytes; + if (b->is_data_view) return scr_insp_data_view(b, recurse, depth); if (d->buffer) { /* Buffer-flavored (a stream chunk): Node's form. */ return scr_insp_buffer(b); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 83b56bea4..db290a23e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3456,10 +3456,19 @@ ScrDyn *scr_bytes_construct(const ScrDyn *callee, const ScrDyn *args, const ScrS /* Shared view metadata and indexed reads for both compiler backends. */ ScrDyn *scr_dyn_bytes_key_get(const ScrDyn *value, const ScrStr *key) { ScrBytes *bytes = value->v.bytes; - if (key->len == 6 && memcmp(key->data, "length", 6) == 0) return scr_dyn_new_num(scr_bytes_len(bytes)); if (key->len == 10 && memcmp(key->data, "byteLength", 10) == 0) return scr_dyn_new_num(scr_bytes_byte_len(bytes)); if (key->len == 10 && memcmp(key->data, "byteOffset", 10) == 0) return scr_dyn_new_num(scr_bytes_byte_offset(bytes)); if (key->len == 6 && memcmp(key->data, "buffer", 6) == 0) return scr_array_buffer_from_bytes(bytes); + if (bytes->is_data_view) { + /* A DataView has no length or indexed elements. */ + if (key->len == 11 && memcmp(key->data, "constructor", 11) == 0) { + static const char message[] = "DataView's constructor through unknown is not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + return NULL; + } + return scr_dyn_retain(scr_dyn_undefined()); + } + if (key->len == 6 && memcmp(key->data, "length", 6) == 0) return scr_dyn_new_num(scr_bytes_len(bytes)); if (key->len == 11 && memcmp(key->data, "constructor", 11) == 0) { if (!value->buffer) return scr_bytes_constructor_for(bytes->elem); /* Buffer retains its separate builtin identity. */ @@ -6364,7 +6373,7 @@ ScrStr *scr_dyn_object_tag(const ScrDyn *d) { ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, "[object "); - scr_jb_puts(&b, scr_bytes_elem_name(d->v.bytes->elem)); + scr_jb_puts(&b, d->v.bytes->is_data_view ? "DataView" : scr_bytes_elem_name(d->v.bytes->elem)); scr_jb_putc(&b, ']'); return scr_jb_finish(&b); } @@ -7059,6 +7068,8 @@ ScrStr *scr_dyn_to_string(const ScrDyn *d, const ScrStr *enc) { return d->v.symbol.render(d->v.symbol.value); case SCR_DYN_BYTES: if (d->buffer) return scr_bytes_to_str(d->v.bytes, enc); + /* DataView has no toString of its own: Object.prototype.toString. */ + if (d->v.bytes->is_data_view) return scr_str_new("[object DataView]", 17); { ScrStr *separator = scr_str_new(",", 1); ScrStr *out = scr_bytes_join(d->v.bytes, separator); @@ -8211,7 +8222,7 @@ static const char *scr_dyn_kind_name(const ScrDyn *d) { case SCR_DYN_ARR: return "array"; case SCR_DYN_OBJ: return "object"; case SCR_DYN_UNDEF: return "undefined"; - case SCR_DYN_BYTES: return scr_bytes_elem_name(d->v.bytes->elem); + case SCR_DYN_BYTES: return d->v.bytes->is_data_view ? "DataView" : scr_bytes_elem_name(d->v.bytes->elem); case SCR_DYN_FUNC: return "function"; case SCR_DYN_HANDLE: return scr_dyn_handle_cls(d); /* "got IncomingMessage" */ case SCR_DYN_PROMISE: return "Promise"; /* "got Promise" */ diff --git a/tests/corpus/6346-inspect-dyn-dataview.ts b/tests/corpus/6346-inspect-dyn-dataview.ts new file mode 100644 index 000000000..ff4acfafd --- /dev/null +++ b/tests/corpus/6346-inspect-dyn-dataview.ts @@ -0,0 +1,41 @@ +// A DataView reached through `unknown` keeps its own behavior: Node's +// `DataView { [byteLength], [byteOffset], [buffer] }` inspection, the +// "[object DataView]" string and tag, no length or indexed elements, and +// no typed array methods. +import { inspect } from "node:util"; + +function show(label: string, value: unknown): void { + console.log(label, value); +} + +const buf = new ArrayBuffer(8); +const whole = new DataView(buf); +whole.setUint16(0, 0x1234); +show("whole", whole); +show("offset", new DataView(buf, 2, 4)); +show("empty", new DataView(new ArrayBuffer(0))); +show("big", new DataView(new ArrayBuffer(120))); +show("nested", { view: new DataView(buf, 6) }); +show("shared", new DataView(new SharedArrayBuffer(2))); + +const u: unknown = whole; +console.log(inspect({ a: { u } }, { depth: 1 }), inspect([u], { depth: 0 })); +console.log(String(u), Object.prototype.toString.call(u), `${u}`); + +const view: unknown = new DataView(buf, 1); +const o = view as { [k: string]: unknown }; +console.log(o.length, o[0], o.byteLength, o.byteOffset); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const loose = view as any; +console.log(loose.toString(), loose.valueOf() === view); +for (const method of ["at", "join", "fill", "slice"]) { + try { + loose[method](0); + } catch (e) { + console.log(method, (e as Error).name); + } +} +console.log(view instanceof DataView, ArrayBuffer.isView(view)); +// A typed array through `unknown` is unaffected. +const bytes: unknown = new Uint8Array([1, 2]); +console.log(bytes, String(bytes), `${bytes}`, (bytes as Uint8Array).length); diff --git a/tests/corpus/6347-dataview-methods-js.cjs b/tests/corpus/6347-dataview-methods-js.cjs new file mode 100644 index 000000000..680aaa011 --- /dev/null +++ b/tests/corpus/6347-dataview-methods-js.cjs @@ -0,0 +1,25 @@ +'use strict'; +// A DataView held in an untyped binding answers its own methods and +// Object.prototype's, and none of the typed array methods. +const view = [new DataView(new ArrayBuffer(4))][0]; +function attempt(label, f) { + try { + console.log(label, f()); + } catch (e) { + console.log(label, `${e.name}: ${e.message}`); + } +} +function use(v) { + v.setUint16(0, 0x1234); + attempt('get', () => [v.getUint16(0), v.getUint8(1)].join(' ')); + attempt('toString', () => v.toString()); + attempt('toLocaleString', () => v.toLocaleString()); + attempt('valueOf', () => v.valueOf() === v); + attempt('join', () => v.join('-')); + attempt('at', () => v.at(0)); + attempt('fill', () => v.fill(1)); + attempt('slice', () => v.slice(0, 1)); + attempt('indexOf', () => v.indexOf(0)); + attempt('string', () => `${String(v)} ${v}`); +} +use(view); diff --git a/tests/harness/corpus-costs/6346-inspect-dyn-dataview.ts.json b/tests/harness/corpus-costs/6346-inspect-dyn-dataview.ts.json new file mode 100644 index 000000000..2b1382076 --- /dev/null +++ b/tests/harness/corpus-costs/6346-inspect-dyn-dataview.ts.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 2200 } diff --git a/tests/harness/corpus-costs/6347-dataview-methods-js.cjs.json b/tests/harness/corpus-costs/6347-dataview-methods-js.cjs.json new file mode 100644 index 000000000..e053739a0 --- /dev/null +++ b/tests/harness/corpus-costs/6347-dataview-methods-js.cjs.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 2000 } From 0b98a8c71340bc7d12914c330359ecdbd429a3a6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:28:36 -0500 Subject: [PATCH 66/85] Read an Error subclass's own code through NodeJS.ErrnoException `(err as NodeJS.ErrnoException).code` read the runtime error's errno slot, which only runtime errors set, so an Error subclass with its own `code` field answered undefined where Node reads the own property. When a class in the program under the receiver's class has its own `code` field or a dynamic property bag, the read goes through the error's dynamic view, which answers the own property and the errno code alike. --- .../src/frontend/lowering/builtins/errors.ts | 36 +++++++++++++-- .../corpus/6348-errno-code-own-field.ts.json | 6 +++ tests/corpus/6348-errno-code-own-field.ts | 46 +++++++++++++++++++ .../6348-errno-code-own-field.ts.json | 1 + 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6348-errno-code-own-field.ts.json create mode 100644 tests/corpus/6348-errno-code-own-field.ts create mode 100644 tests/harness/corpus-costs/6348-errno-code-own-field.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 0313cdd10..a8b4dbcd6 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -1,7 +1,16 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; -import { BOOL, CAUGHT, DYN, F64, type IrExpr, type IrType, STRING } from "../../../ir/ir.js"; +import { + BOOL, + CAUGHT, + DYN, + DYN_CLASS_PROPERTIES, + F64, + type IrExpr, + type IrType, + STRING, +} from "../../../ir/ir.js"; const ASSERTION_ERROR_PROPERTIES: ReadonlySet = new Set([ "actual", @@ -126,7 +135,13 @@ export function lowerErrorCodeProperty( declared, ); } - const systemField = ["errno", "syscall", "path", "dest"].includes(expr.name.text); + // A program class under the receiver's class can carry its own `code` + // (a declared field or a property added through an untyped reference), + // which Node reads instead of the errno code: read through the error's + // dynamic view, which answers both. + const systemField = + ["errno", "syscall", "path", "dest"].includes(expr.name.text) || + (expr.name.text === "code" && programClassMayOwnCode(lowerer, recvT.className)); if ( !systemField && expr.name.text !== "code" && @@ -162,7 +177,9 @@ export function lowerErrorCodeProperty( type: DYN, loc, }, - lowerer.withUndefinedArm(expr.name.text === "errno" ? F64 : STRING), + expr.name.text === "code" + ? lowerer.envValueType() + : lowerer.withUndefinedArm(expr.name.text === "errno" ? F64 : STRING), ); } return { @@ -184,6 +201,19 @@ export function lowerErrorCodeProperty( }; } +/** Whether a class declared in the program, `className` or one of its + * subclasses, has its own `code` field or a dynamic property bag. The + * runtime error classes keep `code` in the errno slot. */ +function programClassMayOwnCode(lowerer: Lowerer, className: string): boolean { + for (const [name, info] of lowerer.classes) { + if (name.startsWith("%")) continue; + if (name !== className && !lowerer.isSubclassOf(name, className)) continue; + if (info.def.fields.some((f) => f.name === "code" || f.name === DYN_CLASS_PROPERTIES)) + return true; + } + return false; +} + /** Whether values of `type` are errors: class instances whose class is * %Error or extends it. A subclass of a class that does not extend Error * cannot be an error either, so the answer depends on the static class diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6348-errno-code-own-field.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6348-errno-code-own-field.ts.json new file mode 100644 index 000000000..b5c392112 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6348-errno-code-own-field.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6348-errno-code-own-field.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6348-errno-code-own-field.ts b/tests/corpus/6348-errno-code-own-field.ts new file mode 100644 index 000000000..02a3d722a --- /dev/null +++ b/tests/corpus/6348-errno-code-own-field.ts @@ -0,0 +1,46 @@ +// `(err as NodeJS.ErrnoException).code` reads an Error subclass's own +// `code` field, as Node reads the own property, and still reads the errno +// code of runtime errors and undefined for plain errors. +import { readFileSync } from "node:fs"; + +class AppError extends Error { + code: string; + constructor(message: string, code = "E_APP") { + super(message); + this.name = "AppError"; + this.code = code; + } +} +class DbError extends AppError { + constructor() { + super("db", "E_DB"); + } +} +class Later extends Error { + code?: string; +} + +function codeOf(err: unknown): string { + if (err instanceof Error) return (err as NodeJS.ErrnoException).code ?? "(none)"; + return "(not an error)"; +} + +console.log(codeOf(new AppError("direct"))); +console.log(codeOf(new DbError())); +const wrapped = new TypeError("wrapped", { cause: new AppError("cause", "E_CAUSE") }); +console.log(codeOf(wrapped.cause), codeOf(wrapped)); +try { + throw new DbError(); +} catch (err) { + console.log(codeOf(err)); +} +const later = new Later("later"); +console.log(codeOf(later)); +later.code = "E_LATER"; +console.log(codeOf(later)); +try { + readFileSync("/nonexistent/scriptc-corpus-6348"); +} catch (err) { + console.log(codeOf(err)); +} +console.log(codeOf(new Error("plain")), codeOf(new RangeError("range")), codeOf("text")); diff --git a/tests/harness/corpus-costs/6348-errno-code-own-field.ts.json b/tests/harness/corpus-costs/6348-errno-code-own-field.ts.json new file mode 100644 index 000000000..28b033d9b --- /dev/null +++ b/tests/harness/corpus-costs/6348-errno-code-own-field.ts.json @@ -0,0 +1 @@ +{ "plain": 1200, "san": 1500 } From 1a65f234c932bd3b8efc0828a2bb5e1246268227 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:35:16 -0700 Subject: [PATCH 67/85] Narrow unions of records by intersection refinements, and keep members an assertion attaches 'k' in u answers per value on arms that declare k as an optional slot. A predicate or 'in' narrowing that intersects record arms views the value through the stored arms that can hold it. An assertion 'r as R & { m: T }' gives R's arms m as an optional member, keeps the object, and a binding initialized by it keeps the stored type, so writes through it reach the original. Late writes through a union receiver count as writes through the binding it was read from. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/assertion-members.ts | 167 ++++++++++++++++++ .../lowering/expressions/hosted-narrowing.ts | 157 ++++++++++++++++ .../lowering/expressions/narrowing.ts | 35 ++++ .../lowering/expressions/relational.ts | 89 +++++++++- .../frontend/lowering/key-order-witness.ts | 20 ++- .../src/frontend/lowering/lower-stmts.ts | 22 +++ .../compiler/src/frontend/lowering/lowerer.ts | 9 + packages/compiler/src/frontend/type-mapper.ts | 53 +++++- .../6460-in-union-optional-slots.ts.json | 6 + ...1-predicate-intersection-narrowing.ts.json | 6 + .../6462-assertion-attached-members.ts.json | 6 + tests/corpus/6460-in-union-optional-slots.ts | 53 ++++++ .../6461-predicate-intersection-narrowing.ts | 70 ++++++++ .../corpus/6462-assertion-attached-members.ts | 35 ++++ 14 files changed, 716 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/assertion-members.ts create mode 100644 packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json create mode 100644 tests/corpus/6460-in-union-optional-slots.ts create mode 100644 tests/corpus/6461-predicate-intersection-narrowing.ts create mode 100644 tests/corpus/6462-assertion-attached-members.ts diff --git a/packages/compiler/src/frontend/lowering/assertion-members.ts b/packages/compiler/src/frontend/lowering/assertion-members.ts new file mode 100644 index 000000000..5a578720e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/assertion-members.ts @@ -0,0 +1,167 @@ +/* Members a type assertion attaches to record values. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { type IrType, typeEquals } from "../../ir/ir.js"; +import { hostsAll } from "./expressions/hosted-narrowing.js"; + +/** `route as Route & { order: number; internal: boolean }` followed by + * `view.internal = true` adds a property to the very object `route` + * names: Node has no layouts, so every later read of `route` (its JSON, + * its keys, a spread) sees the member. Native records are closed structs, + * and the asserted intersection maps to a different struct, so neither a + * conversion (a copy that severs the write from `route`) nor an erasure + * (a struct without the slot) is faithful. + * + * This pass finds such assertions before any body lowers and gives each + * record arm of the operand's type the asserted members it does not + * declare, as optional fields. Values the program builds hold them absent, + * which is invisible to every key surface (`in`, Object.keys, JSON, + * inspect, spread). The asserted type then refines the stored arms + * instead of naming new ones (hosted-narrowing.ts), so the assertion keeps + * the value and its writes land in the stored object. + * + * Only the plain form qualifies: one intersection whose constituents are + * the operand's own type and type literals of required property + * signatures, over plain data types (object types, aliases and interfaces; + * not classes or the standard library's types). Arms that are tuples or + * carry an index signature, members whose type does not map, and members + * two assertions type differently are left alone: those assertions keep + * their exact-shape diagnostics. Assertions whose operand already declares + * every member are skipped before any type maps, so programs without the + * form intern exactly the types they did before. */ +export function extendAssertedRecordMembers( + lowerer: Lowerer, + files: readonly ts.SourceFile[], +): void { + const additions = new Map>(); + const conflicts = new Set(); + const consider = (node: ts.AsExpression | ts.TypeAssertion): void => { + let typeNode: ts.TypeNode = node.type; + while (ts.isParenthesizedTypeNode(typeNode)) typeNode = typeNode.type; + if (!ts.isIntersectionTypeNode(typeNode)) return; + const parts = typeNode.types.nodes; + const literals = parts.filter((p) => ts.isTypeLiteralNode(p)); + const bases = parts.filter((p) => !ts.isTypeLiteralNode(p)); + if (literals.length === 0 || bases.length !== 1) return; + const memberNodes: { name: string; type: ts.TypeNode }[] = []; + for (const literal of literals) { + if (!ts.isTypeLiteralNode(literal)) return; + for (const member of literal.members.nodes) { + // A required member: the form that writes through the view. An + // optional one only reads, and keeps its exact-shape fence. + if (!ts.isPropertySignatureDeclaration(member) || !member.type || member.questionToken) + return; + const name = ts.isIdentifier(member.name) + ? member.name.text + : ts.isStringLiteral(member.name) + ? member.name.text + : null; + if (name === null || name.startsWith("%")) return; + memberNodes.push({ name, type: member.type }); + } + } + // Checker-only gate first: an assertion every constituent of whose + // operand type already declares the members (`e as Expr & { kind: + // "mul" }`, the refinement idiom) attaches nothing, and mapping its + // types here would intern them out of the usual order. + const operandTs = lowerer.typeOf(node.expression); + const constituents = operandTs.isUnionType() ? ts.constituentTypes(operandTs) : [operandTs]; + const declares = (t: ts.Type, name: string): boolean => + lowerer.checker.getPropertyOfType(t, name) !== undefined; + if (constituents.every((t) => memberNodes.every((m) => declares(t, m.name)))) return; + // Plain data types only: object literal types, aliases and interfaces + // (not classes, and not the standard library's types). + const dataType = (t: ts.Type): boolean => { + const symbol = t.getAliasSymbol() ?? t.getSymbol(); + return ( + !!symbol && + (symbol.flags & ts.SymbolFlags.Class) === 0 && + symbol.declarations.length > 0 && + symbol.declarations.every( + (d) => + !lowerer.isStdlibFile(d.getSourceFile()) && + (ts.isTypeLiteralNode(d) || + ts.isInterfaceDeclaration(d) || + ts.isTypeAliasDeclaration(d)), + ) + ); + }; + if (!constituents.every(dataType)) return; + const operand = lowerer.mapTypeOf(operandTs); + const base = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(bases[0]!)); + if (!operand || !base || !typeEquals(operand, base)) return; + const arms = + operand.kind === "union" ? (lowerer.unions.get(operand.unionId)?.arms ?? []) : [operand]; + if (arms.length === 0) return; + const shapeIds: string[] = []; + for (const arm of arms) { + if (arm.kind !== "record") return; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple || shape.indexValue) return; + shapeIds.push(arm.shapeId); + } + const members: { name: string; type: IrType }[] = []; + for (const { name, type } of memberNodes) { + if (shapeIds.every((id) => lowerer.shapes.get(id)!.fields.some((f) => f.name === name))) + continue; + const mapped = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(type)); + if (!mapped || mapped.kind === "void") return; + const optional = lowerer.withUndefinedArmOf(mapped); + if (!optional) return; + members.push({ name, type: optional }); + } + for (const shapeId of shapeIds) { + const shape = lowerer.shapes.get(shapeId)!; + for (const member of members) { + if (shape.fields.some((f) => f.name === member.name)) continue; + let added = additions.get(shapeId); + if (!added) additions.set(shapeId, (added = new Map())); + const prior = added.get(member.name); + if (prior && !typeEquals(prior, member.type)) conflicts.add(`${shapeId}:${member.name}`); + else added.set(member.name, member.type); + } + } + }; + const visit = (node: ts.Node): void => { + if (ts.isAsExpression(node) || ts.isTypeAssertion(node)) consider(node); + ts.forEachChild(node, visit); + }; + for (const sf of files) { + if (sf.isDeclarationFile) continue; + // Cheap text gate: the form needs an intersection with a type literal. + if (!/&\s*\{/.test(sf.text)) continue; + visit(sf); + } + for (const [shapeId, added] of additions) { + const extra = [...added] + .filter(([name]) => !conflicts.has(`${shapeId}:${name}`)) + .map(([name, type]) => ({ name, type })); + lowerer.shapes.extendShape(shapeId, extra); + } + if (additions.size > 0) keepStoredTypeOfAssertedGlobals(lowerer, files); +} + +/** A module-level `const view = value as T & { member: X }` binding keeps + * the stored type of `value` once the members are attached, exactly as a + * local binding does (lowerVarDecl): the assertion keeps the object, and + * writes through the binding must reach it. Globals took their checker + * type during collection, before the members existed. */ +function keepStoredTypeOfAssertedGlobals(lowerer: Lowerer, files: readonly ts.SourceFile[]): void { + for (const sf of files) { + if (sf.isDeclarationFile) continue; + for (const statement of sf.statements.nodes) { + if (!ts.isVariableStatement(statement)) continue; + if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue; + for (const decl of statement.declarationList.declarations.nodes) { + if (!ts.isIdentifier(decl.name) || decl.type || !decl.initializer) continue; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (!ts.isAsExpression(init) && !ts.isTypeAssertion(init)) continue; + const global = lowerer.globalOf(decl.name); + const stored = lowerer.mapTypeOf(lowerer.typeOf(init.expression)); + if (!global || !stored || (stored.kind !== "union" && stored.kind !== "record")) continue; + if (hostsAll(lowerer, stored, global.type)) global.type = stored; + } + } + } +} diff --git a/packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts new file mode 100644 index 000000000..87a7ba38e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/hosted-narrowing.ts @@ -0,0 +1,157 @@ +/* Narrowings that refine record arms without naming a stored arm. */ +import type { Lowerer } from "../lowerer.js"; +import { type IrExpr, type IrType, type SrcLoc, typeEquals } from "../../../ir/ir.js"; + +/** A checker narrowing can refine the arms of a union of records with an + * intersection: a predicate `r is Route & { path: string }`, or the + * `"path" in r` narrowing, which intersects arms that do not declare the + * name with `Record<"path", unknown>`. Each narrowed arm then maps to a + * new record shape that is not an arm of the stored union, although the + * value never changed layout. A stored record arm HOSTS a narrowed record + * when both name the same members, each narrowed member refined to a + * subset of the stored slot type (or to `unknown`); a member an assertion + * attached to the stored shape (assertion-members.ts) may be missing from + * the narrowed one. A required member the stored arm does not declare + * cannot exist on its values: native records are closed, so `in` already + * answers false there and the narrowed arm is uninhabited. + * + * Only unions of two or more record arms without a literal discriminant + * qualify: discriminated unions narrow by their discriminant, and a + * record with unit arms keeps its own predicate-strengthening reads. + * + * Returns the stored arms that can hold a value of the narrowed type, or + * null when the union does not qualify, when some narrowed arm is neither + * a stored arm nor a record, or when no stored arm is excluded (there is + * nothing to narrow). */ +export function hostedArmTags( + lowerer: Lowerer, + fromId: string, + narrowed: IrType, +): Set | null { + const from = lowerer.unions.get(fromId); + if (!from || !recordUnion(lowerer, fromId)) return null; + const narrowedArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!narrowedArms || narrowedArms.length === 0) return null; + const allowed = new Set(); + let refined = false; + for (const arm of narrowedArms) { + const direct = lowerer.armTag(fromId, arm); + if (direct >= 0) { + allowed.add(direct); + continue; + } + if (arm.kind !== "record") return null; + refined = true; + from.arms.forEach((stored, tag) => { + if (stored.kind === "record" && recordHosts(lowerer, stored.shapeId, arm.shapeId)) { + allowed.add(tag); + } + }); + } + if (!refined || allowed.size === 0 || allowed.size === from.arms.length) return null; + return allowed; +} + +/** A union of at least two record arms without a literal discriminant. */ +function recordUnion(lowerer: Lowerer, unionId: string): boolean { + const def = lowerer.unions.get(unionId); + if (!def || def.discriminant) return false; + return def.arms.filter((arm) => arm.kind === "record").length >= 2; +} + +/** Whether every value of `narrowed` (a checker refinement of a value + * stored as `storage`) lives in the stored layout: each narrowed arm is a + * stored arm or a record a stored record arm hosts (see hostedArmTags), + * and at least one needs the hosting. A union storage must qualify as in + * hostedArmTags; a single record only when an assertion attached members + * to it. A binding initialized by such an assertion keeps the stored type, + * so its member writes reach the asserted object. */ +export function hostsAll(lowerer: Lowerer, storage: IrType, narrowed: IrType): boolean { + if (typeEquals(storage, narrowed)) return false; + let storedArms: readonly IrType[]; + if (storage.kind === "union") { + if (!recordUnion(lowerer, storage.unionId)) return false; + storedArms = lowerer.unions.get(storage.unionId)!.arms; + } else if (storage.kind === "record" && lowerer.shapes.attachedNames(storage.shapeId).length) { + storedArms = [storage]; + } else { + return false; + } + const narrowedArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!narrowedArms || narrowedArms.length === 0) return false; + let refined = false; + for (const arm of narrowedArms) { + if (storedArms.some((stored) => typeEquals(stored, arm))) continue; + if (arm.kind !== "record") return false; + const hosted = storedArms.some( + (stored) => stored.kind === "record" && recordHosts(lowerer, stored.shapeId, arm.shapeId), + ); + if (!hosted) return false; + refined = true; + } + return refined; +} + +function recordHosts(lowerer: Lowerer, storedId: string, narrowedId: string): boolean { + const stored = lowerer.shapes.get(storedId); + const narrowed = lowerer.shapes.get(narrowedId); + if (!stored || !narrowed || stored.tuple || narrowed.tuple) return false; + if (stored.indexValue || narrowed.indexValue) return false; + const attached = lowerer.shapes.attachedNames(storedId); + for (const sf of stored.fields) { + if (!narrowed.fields.some((nf) => nf.name === sf.name) && !attached.includes(sf.name)) + return false; + } + for (const nf of narrowed.fields) { + const sf = stored.fields.find((f) => f.name === nf.name); + if (!sf || !slotRefines(lowerer, nf.type, sf.type)) return false; + } + return true; +} + +/** Whether a stored slot of type `stored` can be read as `narrowed`: the + * same type, `unknown`, or a subset of the stored union's arms (a read + * site narrows from the stored slot exactly as it does for a predicate + * strengthening one record). */ +function slotRefines(lowerer: Lowerer, narrowed: IrType, stored: IrType): boolean { + if (typeEquals(narrowed, stored) || narrowed.kind === "dyn") return true; + if (stored.kind !== "union") return false; + if (narrowed.kind !== "union") return lowerer.armTag(stored.unionId, narrowed) >= 0; + const arms = lowerer.unions.get(narrowed.unionId)?.arms; + return !!arms && arms.every((arm) => lowerer.armTag(stored.unionId, arm) >= 0); +} + +/** The stored view of a hosted narrowing (see hostedArmTags): one stored + * record arm extracted with the checked helper, or the sub-union of the + * hosting arms re-tagged with the excluded arms as traps. Both keep the + * value's stored layout, so member reads and writes reach the same + * object. Null when the narrowing is not a hosted one. */ +export function hostedStorageView( + lowerer: Lowerer, + value: IrExpr, + narrowed: IrType, + loc: SrcLoc, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const fromId = value.type.unionId; + const allowed = hostedArmTags(lowerer, fromId, narrowed); + if (!allowed) return null; + const from = lowerer.unions.get(fromId)!; + if (allowed.size === 1) { + const arm = from.arms[[...allowed][0]!]!; + const helper = lowerer.narrowedArmHelper(fromId, arm, loc); + return helper ? lowerer.checkedNarrowCall(helper, value, arm, loc) : null; + } + const arms = from.arms.filter((_, tag) => allowed.has(tag)); + const subId = lowerer.unions.transform(from, arms); + const trappable = new Set(); + from.arms.forEach((_, tag) => { + if (!allowed.has(tag)) trappable.add(tag); + }); + const helper = lowerer.unionRetagHelper(fromId, subId, loc, trappable); + if (!helper) return null; + const type: IrType = { kind: "union", unionId: subId }; + return { kind: "call", callee: helper, args: [value], type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 207000717..8bfb7bc3f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -29,6 +29,7 @@ import { isOptionalProcessStreamProperty } from "../builtins/process.js"; import { literalValues } from "../../literal-values.js"; import { literalUnionArm } from "../../union-discriminants.js"; import { checkedArmExtraction } from "../coercions/unions.js"; +import { hostedStorageView, hostsAll } from "./hosted-narrowing.js"; import { nodeThrowExpr, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; import { @@ -659,6 +660,12 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx }; } } + // A narrowing that refines record arms with an intersection keeps the + // stored layout: view the value as the stored arms that can hold it. + if (narrowed?.kind === "union") { + const hosted = hostedStorageView(lowerer, expr, narrowed, expr.loc); + if (hosted) return hosted; + } // A checker type narrowed to a UNIT arm (the `=== undefined` branch) // also stays union-typed: a unit arm has no payload to extract, and // nothing useful reads such a value anyway. (Standalone undefined maps @@ -668,6 +675,14 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx return expr; } const tag = lowerer.armTag(expr.type.unionId, narrowed); + if ( + tag < 0 && + narrowed.kind === "record" && + !lowerer.unions.get(expr.type.unionId)?.discriminant + ) { + const hosted = hostedStorageView(lowerer, expr, narrowed, expr.loc); + if (hosted) return hosted; + } if (tag < 0) { // The checker may refine fields inside a discriminated record too. // That creates a different mapped shape, but does not change the @@ -926,6 +941,10 @@ export function runtimeOptionalReceiverRead( loc, ); if (!helper) { + // Intersection-refined record arms read through their stored + // layout (the predicate `r is Route & { path: string }`). + const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc); + if (hosted) return hosted; lowerer.unsupported( "SC1090", expr, @@ -946,6 +965,10 @@ export function runtimeOptionalReceiverRead( // path below; mixed storage also needs to validate its concrete class. if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view; } + if (narrowed.kind === "record" && lowerer.armTag(local.type.unionId, narrowed) < 0) { + const hosted = hostedStorageView(lowerer, varRef(local.id, local.type, loc), narrowed, loc); + if (hosted) return hosted; + } const undefTag = lowerer.armTag(local.type.unionId, UNDEFINED_T); const def = lowerer.unions.get(local.type.unionId); if (!def || undefTag < 0) @@ -1286,6 +1309,12 @@ export function lowerAsExpression( ) { return lowerer.coerceInto(expr, inner, target); } + // `job as Job & { startedAt: number }` on a record: the asserted + // record refines the stored one, so the assertion keeps the object + // (a width copy would sever writes through the asserted view). + if (inner.type.kind === "record" && target && hostsAll(lowerer, inner.type, target)) { + return inner; + } // Static assertions normally erase, but record layouts are // monomorphic: a consumer selected from the asserted shape must see // that shape physically. Reuse the ordinary slot coercion so plain @@ -1312,6 +1341,12 @@ export function lowerAsExpression( return lowerer.checkedNarrowCall(helper, inner, target, locOf(expr)); } } + // An intersection refining the stored record arms keeps the value + // (hosted-narrowing.ts): the stored arms that can hold it. + if (target && (target.kind === "union" || target.kind === "record")) { + const hosted = hostedStorageView(lowerer, inner, target, locOf(expr)); + if (hosted) return hosted; + } } // `node.parent as Sub | undefined`: a union target whose class arm is // narrower than the stored class gets the same checked class view as diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 55baa8014..ee9fe4183 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -30,7 +30,7 @@ import { lowerCryptoKeyInstanceof } from "../builtins/webcrypto.js"; import { lowerNetAddressInstanceof } from "../builtins/net-address.js"; import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js"; import { maybePromiseInstanceOf } from "../maybe-promise.js"; -import { isSafeToDiscard } from "./evaluation-safety.js"; +import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; import { type ClassInfo, storedClassValueType } from "../lower-classes.js"; import { classInstanceOf } from "../class-dynamic-dispatch.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -1349,13 +1349,14 @@ export function lowerInExpression( // arm answers membership STATICALLY (a declared non-optional field is // always present, an undeclared name never is), so the whole test // collapses to runtime TAG tests — tsc's own narrowing then types the - // branches (the discriminating-`in` idiom). Optional (undefined-armed) - // fields and tuple/index-signature arms answer per VALUE, not per arm - // — those unions keep the fence below. + // branches (the discriminating-`in` idiom). An arm declaring the key + // as an optional (undefined-armed) slot answers per VALUE instead: + // its tag test joins that payload's presence test (see + // perValueUnionIn). Tuple/index-signature arms keep the fence below. if (recv.type.kind === "union") { const unionId = recv.type.unionId; const arms = lowerer.unions.get(unionId)?.arms ?? []; - const answers: { tag: number; has: boolean }[] = []; + const answers: { tag: number; has: boolean | "slot"; arm: IrType }[] = []; let staticAnswers = arms.length > 0; for (const arm of arms) { const tag = lowerer.armTag(unionId, arm); @@ -1372,14 +1373,17 @@ export function lowerInExpression( f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 ) { - staticAnswers = false; // an optional slot: presence is per-value - break; + answers.push({ tag, has: "slot", arm }); // presence is per-value + continue; } // Accessor properties are own properties to `in` (Node answers // true without invoking the getter) — either slot present makes // the name a member. const acc = shape.fields.some((x) => x.name === `%get:${key}` || x.name === `%set:${key}`); - answers.push({ tag, has: f !== undefined || acc || inherited }); + answers.push({ tag, has: f !== undefined || acc || inherited, arm }); + } + if (staticAnswers && answers.some((a) => a.has === "slot")) { + return perValueUnionIn(lowerer, recv, unionId, key, answers, loc); } if (staticAnswers) { const pureRecv = @@ -1531,6 +1535,75 @@ export function lowerInExpression( ); } +/** `"k" in u` over a union of fixed record shapes where some arm declares + * `k` as an optional slot. Arms without the name answer false and arms + * with an always-present member answer true, both by tag; an optional + * slot answers its payload's own-property presence (an explicit + * undefined is present, an omitted or deleted property is not), read + * through the checked arm extraction the tag test has just proven. The + * receiver evaluates once: a non-repeatable one is stabilized in a + * hidden local first. */ +function perValueUnionIn( + lowerer: Lowerer, + recv: IrExpr, + unionId: string, + key: string, + answers: readonly { tag: number; has: boolean | "slot"; arm: IrType }[], + loc: SrcLoc, +): IrExpr { + const recvType = recv.type; + const stable = isSafeToRepeat(recv) ? null : lowerer.declareHiddenLocal("%inRecv", recvType); + const ref = (): IrExpr => (stable ? varRef(stable.id, recvType, loc) : recv); + const isTag = (tag: number): IrExpr => ({ + kind: "unionIsTag", + unionId, + tag, + negated: false, + value: ref(), + type: BOOL, + loc, + }); + let out: IrExpr | null = null; + for (const a of answers) { + if (a.has === false) continue; + let test = isTag(a.tag); + if (a.has === "slot" && a.arm.kind === "record") { + test = { + kind: "logical", + op: "&&", + left: test, + right: { + kind: "recordHas", + obj: { + kind: "unionNarrow", + unionId, + tag: a.tag, + value: ref(), + type: a.arm, + loc, + }, + shapeId: a.arm.shapeId, + field: key, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + out = out ? { kind: "logical", op: "||", left: out, right: test, type: BOOL, loc } : test; + } + const result: IrExpr = out ?? { kind: "boolLit", value: false, type: BOOL, loc }; + if (!stable) return result; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: recv, loc }], + result, + type: BOOL, + loc, + }; +} + /** The runtime-key `in` (see lowerInExpression): `k in r` where k is a * runtime string and r an index-signature record — an interned * `%rec.haskey.(k, r)` walks the declared names (a string-equality diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.ts b/packages/compiler/src/frontend/lowering/key-order-witness.ts index ba8001792..437a29563 100644 --- a/packages/compiler/src/frontend/lowering/key-order-witness.ts +++ b/packages/compiler/src/frontend/lowering/key-order-witness.ts @@ -224,12 +224,30 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio const loops: number[] = []; let loopIds = 0; const loopKey = (): string => loops.join("/"); + /** Bindings initialized by another binding's reference (`const view = + * value`, the snapshot a union-receiver write takes), so writes through + * either count as writes through the one object. */ + const aliases = new Map(); + const bindingOf = (obj: IrExpr): string | null => { + let o = obj; + while (o.kind === "unionNarrow" || o.kind === "downcast") o = o.value; + if (o.kind !== "varRef") return null; + let id = o.localId; + const seen = new Set(); + for (;;) { + const alias = aliases.get(id); + if (!alias || alias.fn !== fnName || inits.get(id)?.count !== 1 || seen.has(id)) return id; + seen.add(id); + id = alias.target; + } + }; const noteInit = (id: string, init: IrExpr): void => { const previous = inits.get(id); if (previous !== undefined) { previous.count++; return; } + if (init.kind === "varRef") aliases.set(id, { target: init.localId, fn: fnName }); const fresh = init.kind === "recordLit" || init.kind === "recordClone" || @@ -315,7 +333,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio field: st.field, kind: st.value.kind === "fieldAbsent" ? "delete" : "write", fn: fnName, - binding: st.obj.kind === "varRef" ? st.obj.localId : null, + binding: bindingOf(st.obj), loop: loopKey(), pos, }); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 41c048ae9..5d9c91634 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,3 +1,4 @@ +import { hostsAll } from "./expressions/hosted-narrowing.js"; import { matchAllRowsReadByElements, storedMatchAllRowsReadByElements, @@ -5471,6 +5472,21 @@ export function lowerVarDecl( settledType = init.type; } } + // `const view = route as Route & { order: number }`: the asserted type + // refines the record arms the value is stored in (the assertion keeps + // that value, see hosted-narrowing.ts). The binding keeps the stored + // type, so writes through it reach the asserted object. + if ( + !isLet && + !decl.type && + decl.initializer && + isAssertionInitializer(decl.initializer) && + (settledType.kind === "union" || settledType.kind === "record") && + (init.type.kind === "union" || init.type.kind === "record") && + hostsAll(lowerer, init.type, settledType) + ) { + settledType = init.type; + } // An exact class behind a generic interface keeps its native // representation so generic calls can specialize against its body. if ( @@ -11198,3 +11214,9 @@ export function stdlibBufferVarAlias(lowerer: Lowerer, decl: ts.VariableDeclarat requireEarlyReference(lowerer.program, decl) === null ); } + +function isAssertionInitializer(node: ts.Expression): boolean { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ts.isAsExpression(e) || ts.isTypeAssertion(e); +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 27011df24..7c385b311 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { extendAssertedRecordMembers } from "./assertion-members.js"; import { jsDynamicDeclarations } from "./js-dynamic-objects.js"; import { lifetimeRegionType, @@ -6710,6 +6711,10 @@ export class Lowerer { } this.collectProgram(parts); this.speculativeTypeQueries(() => { + extendAssertedRecordMembers( + this, + parts.map((fp) => fp.sf), + ); this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); this.analyzeGuardedRecordReadGlobals(parts); @@ -7116,6 +7121,10 @@ export class Lowerer { }; this.collectProgram(parts); this.speculativeTypeQueries(() => { + extendAssertedRecordMembers( + this, + parts.map((fp) => fp.sf), + ); this.analyzeRuntimeOptionalArrayReads(parts); this.analyzeDateJsonResults(parts); this.analyzeGuardedRecordReadGlobals(parts); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 437aba2b8..0a26b4b67 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -325,7 +325,7 @@ export class ShapeRegistry { ) { return; } - shape.declaredOrder = declaredOrder; + shape.declaredOrder = this.withExtendedNames(shape.id, declaredOrder); this.declaredOrderPriority.set(shape.id, this.currentDeclaredOrderPriority); } @@ -341,7 +341,7 @@ export class ShapeRegistry { } const shape = this.byId.get(shapeId); if (!shape) continue; - shape.declaredOrder = best.order; + shape.declaredOrder = this.withExtendedNames(shapeId, best.order); this.declaredOrderPriority.set(shapeId, best.priority); } for (const finalize of this.derivedDeclaredOrderFinalizers) finalize(); @@ -374,7 +374,7 @@ export class ShapeRegistry { // still obeys first-writer-wins. if (shape?.declaredOrder !== originalOrder) return; const next = order(); - if (next !== undefined) shape.declaredOrder = next; + if (next !== undefined) shape.declaredOrder = this.withExtendedNames(shape.id, next); }; } @@ -482,6 +482,53 @@ export class ShapeRegistry { get(shapeId: string): IrRecordShape | undefined { return this.byId.get(shapeId); } + + /** Names extendShape added to a shape, which every key-order table the + * shape adopts lists after its declared names. */ + private readonly extendedNames = new Map(); + + /** The members extendShape attached to a shape (empty for most). */ + attachedNames(shapeId: string): readonly string[] { + return this.extendedNames.get(shapeId) ?? []; + } + + private withExtendedNames(shapeId: string, order: string[]): string[] { + const extra = this.extendedNames.get(shapeId); + if (!extra) return order; + const missing = extra.filter((name) => !order.includes(name)); + return missing.length === 0 ? order : [...order, ...missing]; + } + + /** Adds optional fields to an interned shape in place: members a type + * assertion attaches to the shape's values (`r as R & { tag: string }` + * followed by `view.tag = ...`; see assertion-members.ts). Every value + * of the shape holds them absent until a write through such a view, so + * the shape keeps its id, both structural keys resolve to it, and its + * key-order table lists the new names after the declared ones. Runs + * before any body lowers; existing layouts change, so the revision + * advances like a closing recursive placeholder. */ + extendShape(shapeId: string, extra: readonly { name: string; type: IrType }[]): void { + const shape = this.byId.get(shapeId); + if (!shape || shape.tuple || extra.length === 0) return; + const fresh = extra.filter((f) => !shape.fields.some((x) => x.name === f.name)); + if (fresh.length === 0) return; + shape.fields = [...shape.fields, ...fresh].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + this.extendedNames.set(shapeId, [ + ...(this.extendedNames.get(shapeId) ?? []), + ...fresh.map((f) => f.name), + ]); + if (shape.declaredOrder) + shape.declaredOrder = this.withExtendedNames(shapeId, shape.declaredOrder); + const candidates = this.declaredOrderCandidates.get(shapeId); + if (candidates) { + for (const c of candidates) c.order = this.withExtendedNames(shapeId, c.order); + } + this.revision++; + const key = this.keyOf(shape.fields, false, shape.indexValue); + if (!this.byKey.has(key)) this.byKey.set(key, shapeId); + } } /** The frontend's union interner — mirrors ShapeRegistry. A union's diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json new file mode 100644 index 000000000..8491507eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6460-in-union-optional-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6460-in-union-optional-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json new file mode 100644 index 000000000..3ce078006 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6461-predicate-intersection-narrowing.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6461-predicate-intersection-narrowing.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json new file mode 100644 index 000000000..36e333373 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6462-assertion-attached-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6462-assertion-attached-members.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6460-in-union-optional-slots.ts b/tests/corpus/6460-in-union-optional-slots.ts new file mode 100644 index 000000000..f381217b9 --- /dev/null +++ b/tests/corpus/6460-in-union-optional-slots.ts @@ -0,0 +1,53 @@ +// `"k" in u` over a union of records where some arms declare `k` as an +// optional slot: those arms answer by the value's own property presence +// (an explicit undefined is present, an omitted or deleted key is not), +// the other arms by their tag. +type Cond = { type: string; value?: string }; +type HandleRoute = { handle: string; src?: string; dest?: string; status?: number }; +type SourceRoute = { + src: string; + dest?: string; + headers?: { [key: string]: string }; + middlewarePath?: string; + continue?: boolean; + status?: number; + has?: Cond[]; +}; +type Route = HandleRoute | SourceRoute; + +const routes: Route[] = [ + { handle: "filesystem" }, + { src: "/a", middlewarePath: "/mw" }, + { src: "/b", dest: "/c", status: undefined }, + { handle: "miss", src: "/x", dest: undefined }, + { src: "/d", has: [{ type: "header" }] }, +]; + +for (const r of routes) { + console.log("handle" in r, "middlewarePath" in r, "dest" in r, "status" in r, "has" in r, "toString" in r); +} + +// A deleted optional key leaves the value; a later write brings it back. +const second = routes[1]!; +if ("middlewarePath" in second) { + delete second.middlewarePath; +} +console.log("middlewarePath" in second, JSON.stringify(second)); +if (!("handle" in second)) { + second.middlewarePath = "/again"; +} +console.log("middlewarePath" in second, JSON.stringify(second)); + +// The receiver evaluates once, even when it is a call. +let calls = 0; +function pick(i: number): Route { + calls++; + return routes[i]!; +} +console.log("dest" in pick(2), "dest" in pick(0), "src" in pick(3), calls); + +// `in` as a narrowing test inside a predicate and a filter callback. +function hasMiddleware(route: Route): boolean { + return "middlewarePath" in route && typeof route.middlewarePath === "string"; +} +console.log(routes.filter(hasMiddleware).length, routes.filter((r) => "status" in r).length); diff --git a/tests/corpus/6461-predicate-intersection-narrowing.ts b/tests/corpus/6461-predicate-intersection-narrowing.ts new file mode 100644 index 000000000..7e8ff9d8a --- /dev/null +++ b/tests/corpus/6461-predicate-intersection-narrowing.ts @@ -0,0 +1,70 @@ +// A predicate `r is Route & { path: string }` refines the record arms of +// a union without naming a stored arm: arms that do not declare `path` +// cannot hold such a value, the others are read through their own +// layout. The narrowed value can be read in place, in templates, in +// object literals and from closures. +type HandleRoute = { handle: string; src?: string; dest?: string }; +type SourceRoute = { src: string; dest?: string; path?: string; status?: number }; +type Route = HandleRoute | SourceRoute; + +function isPathRoute(route: Route): route is Route & { path: string } { + return "path" in route && typeof route.path === "string"; +} + +type Output = { path: string; index: number }; + +function collect(result: { names: Set; routes: Route[] }): Map { + const out = new Map(); + for (const route of result.routes) { + if (!isPathRoute(route)) continue; + let name = route.path; + if (!result.names.has(name)) name = name.replace(/^\//, ""); + if (!result.names.has(name)) { + throw new Error(`Mapping ${route.path} not found`); + } + out.set(name, { path: route.path, index: out.get(name)?.index ?? out.size }); + } + return out; +} + +const routes: Route[] = [ + { handle: "filesystem" }, + { src: "/a", path: "/one" }, + { src: "/b" }, + { src: "/c", path: "two" }, + { src: "/d", path: "/one", status: 308 }, +]; +console.log([...collect({ names: new Set(["one", "two"]), routes })]); +try { + collect({ names: new Set(), routes: [{ src: "/z", path: "/missing" }] }); +} catch (e) { + console.log((e as Error).message); +} + +// Closures capture the narrowed binding. +const readers: (() => string)[] = []; +for (const route of routes) { + if (!isPathRoute(route)) continue; + readers.push(() => `${route.src} -> ${route.path}`); +} +console.log(readers.map((read) => read())); + +// Three arms, two of which can hold the refinement: the narrowed value is +// either of them, and their shared members still read. +type A = { kind: string; a: number; tag?: string }; +type B = { kind: string; b: boolean; tag?: string }; +type C = { kind: string; c: string }; +type Item = A | B | C; +function isTagged(item: Item): item is Item & { tag: string } { + return "tag" in item && item.tag !== undefined; +} +const items: Item[] = [ + { kind: "a", a: 1, tag: "x" }, + { kind: "b", b: true }, + { kind: "c", c: "plain" }, + { kind: "b", b: false, tag: "y" }, +]; +for (const item of items) { + if (isTagged(item)) console.log(item.kind, item.tag.toUpperCase()); + else console.log(item.kind, "untagged"); +} diff --git a/tests/corpus/6462-assertion-attached-members.ts b/tests/corpus/6462-assertion-attached-members.ts new file mode 100644 index 000000000..c2a8857fa --- /dev/null +++ b/tests/corpus/6462-assertion-attached-members.ts @@ -0,0 +1,35 @@ +// `route as Route & { order: number; internal: boolean }` followed by +// writes through the asserted view adds the members to the object the +// original binding names: they appear wherever the route is observed +// later, in the order the writes created them. +type HandleRoute = { handle: string; src?: string; dest?: string; status?: number }; +type SourceRoute = { src: string; dest?: string; path?: string; status?: number }; +type Route = HandleRoute | SourceRoute; + +function markInternal(input: Route[], out: Route[], order: number): void { + for (const route of input) { + const marked = route as Route & { internal: boolean; order: number }; + marked.internal = true; + marked.order = order; + out.unshift(route); + } +} + +const out: Route[] = [{ handle: "filesystem" }, { src: "/plain", dest: "/x" }]; +markInternal([{ src: "/a" }, { handle: "miss" }, { src: "/b", status: 404 }], out, 3); +console.log(JSON.stringify(out)); +console.log(out); +console.log(out.map((r) => ("internal" in (r as Route & { internal?: boolean }) ? "marked" : "plain"))); +console.log(out.map((r) => ({ ...r }))); + +// Values the assertion never reached keep no trace of the members. +const fresh: Route = { src: "/new" }; +console.log(JSON.stringify(fresh), Object.keys(fresh)); + +// A single record shape works the same way, and the asserted view reads +// back what it wrote. +type Job = { id: string; retries?: number }; +const job: Job = { id: "j1" }; +const tracked = job as Job & { startedAt: number }; +tracked.startedAt = 42; +console.log(tracked.startedAt + 1, JSON.stringify(job), Object.keys(job)); From cdd78b4502299bbf5b9c3b803ea0a2731f8694d1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:36:14 -0500 Subject: [PATCH 68/85] Answer `in` on typed arrays held as any `in` on a typed array, Buffer or DataView held as `any` or `unknown` answered false for every key: indices, `length`, prototype methods and Object.prototype names. On a typed array, a canonical numeric key (one that ToString(ToNumber(key)) reproduces, or "-0") now answers by bounds: an integral index, not -0, below the length, so "1.5", "-0", "NaN" and "Infinity" answer false. Other keys answer true for members of %TypedArray%.prototype, Buffer.prototype on a Buffer, the constructors' BYTES_PER_ELEMENT and Object.prototype. A DataView has no indexed elements and answers DataView.prototype's members and Object.prototype's. --- .../tests/corpus/6407-dyn-bytes-in.ts.json | 6 ++ .../tests/corpus/6408-dyn-bytes-in-js.js.json | 6 ++ packages/runtime/src/scr_json.c | 65 +++++++++++++++++++ tests/corpus/6407-dyn-bytes-in.ts | 28 ++++++++ tests/corpus/6408-dyn-bytes-in-js.js | 35 ++++++++++ .../corpus-costs/6407-dyn-bytes-in.ts.json | 1 + .../corpus-costs/6408-dyn-bytes-in-js.js.json | 1 + 7 files changed, 142 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6407-dyn-bytes-in.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6408-dyn-bytes-in-js.js.json create mode 100644 tests/corpus/6407-dyn-bytes-in.ts create mode 100644 tests/corpus/6408-dyn-bytes-in-js.js create mode 100644 tests/harness/corpus-costs/6407-dyn-bytes-in.ts.json create mode 100644 tests/harness/corpus-costs/6408-dyn-bytes-in-js.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6407-dyn-bytes-in.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6407-dyn-bytes-in.ts.json new file mode 100644 index 000000000..e4b1b5d88 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6407-dyn-bytes-in.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6407-dyn-bytes-in.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6408-dyn-bytes-in-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6408-dyn-bytes-in-js.js.json new file mode 100644 index 000000000..30bd05d7e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6408-dyn-bytes-in-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6408-dyn-bytes-in-js.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 83b56bea4..5e812fd9b 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2122,6 +2122,70 @@ static bool scr_dyn_array_proto_name(const ScrStr *key) { return scr_dyn_name_in(key, names, sizeof names / sizeof names[0]); } +/* Own string keys of %TypedArray%.prototype, plus BYTES_PER_ELEMENT from + * each typed array's own prototype. */ +static bool scr_dyn_typed_array_proto_name(const ScrStr *key) { + static const char *const names[] = { + "constructor", "buffer", "byteLength", "byteOffset", "length", "entries", "keys", "values", + "at", "copyWithin", "every", "fill", "filter", "find", "findIndex", "findLast", + "findLastIndex", "forEach", "includes", "indexOf", "join", "lastIndexOf", "map", "reverse", + "reduce", "reduceRight", "set", "slice", "some", "sort", "subarray", "toReversed", "toSorted", + "with", "toLocaleString", "toString", "BYTES_PER_ELEMENT" + }; + return scr_dyn_name_in(key, names, sizeof names / sizeof names[0]); +} + +/* Own string keys of Buffer.prototype that %TypedArray%.prototype lacks. */ +static bool scr_dyn_buffer_proto_name(const ScrStr *key) { + static const char *const names[] = { + "readBigUInt64LE", "readBigUInt64BE", "readBigUint64LE", "readBigUint64BE", "readBigInt64LE", + "readBigInt64BE", "writeBigUInt64LE", "writeBigUInt64BE", "writeBigUint64LE", + "writeBigUint64BE", "writeBigInt64LE", "writeBigInt64BE", "readUIntLE", "readUInt32LE", + "readUInt16LE", "readUInt8", "readUIntBE", "readUInt32BE", "readUInt16BE", "readUintLE", + "readUint32LE", "readUint16LE", "readUint8", "readUintBE", "readUint32BE", "readUint16BE", + "readIntLE", "readInt32LE", "readInt16LE", "readInt8", "readIntBE", "readInt32BE", + "readInt16BE", "writeUIntLE", "writeUInt32LE", "writeUInt16LE", "writeUInt8", "writeUIntBE", + "writeUInt32BE", "writeUInt16BE", "writeUintLE", "writeUint32LE", "writeUint16LE", + "writeUint8", "writeUintBE", "writeUint32BE", "writeUint16BE", "writeIntLE", "writeInt32LE", + "writeInt16LE", "writeInt8", "writeIntBE", "writeInt32BE", "writeInt16BE", "readFloatLE", + "readFloatBE", "readDoubleLE", "readDoubleBE", "writeFloatLE", "writeFloatBE", "writeDoubleLE", + "writeDoubleBE", "asciiSlice", "base64Slice", "base64urlSlice", "latin1Slice", "hexSlice", + "ucs2Slice", "utf8Slice", "asciiWrite", "base64Write", "base64urlWrite", "latin1Write", + "hexWrite", "ucs2Write", "utf8Write", "parent", "offset", "copy", "equals", "inspect", + "compare", "write", "toJSON", "swap16", "swap32", "swap64" + }; + return scr_dyn_name_in(key, names, sizeof names / sizeof names[0]); +} + +/* Own string keys of DataView.prototype. */ +static bool scr_dyn_data_view_proto_name(const ScrStr *key) { + static const char *const names[] = { + "constructor", "buffer", "byteLength", "byteOffset", "getInt8", "setInt8", "getUint8", + "setUint8", "getInt16", "setInt16", "getUint16", "setUint16", "getInt32", "setInt32", + "getUint32", "setUint32", "getFloat32", "setFloat32", "getFloat64", "setFloat64", + "getBigInt64", "setBigInt64", "getBigUint64", "setBigUint64", "getFloat16", "setFloat16" + }; + return scr_dyn_name_in(key, names, sizeof names / sizeof names[0]); +} + +/* `in` on a typed array, Buffer or DataView. On a typed array, a canonical + * numeric key (one that ToString(ToNumber(key)) reproduces, or "-0") + * answers by bounds alone: an integral index, not -0, below the length. + * Other keys name members of %TypedArray%.prototype, Buffer.prototype for a + * Buffer, or Object.prototype. A DataView has no indexed elements: its keys + * name members of DataView.prototype or Object.prototype. */ +static bool scr_dyn_bytes_in(const ScrDyn *v, const ScrStr *key) { + if (v->v.bytes->is_data_view) return scr_dyn_data_view_proto_name(key) || scr_dyn_object_proto_name(key); + if (key->len == 2 && memcmp(key->data, "-0", 2) == 0) return false; + double index = scr_string_to_number(key); + ScrStr *canonical = scr_f64_to_scrstr(index); + bool numeric = canonical->len == key->len && memcmp(canonical->data, key->data, key->len) == 0; + scr_str_release(canonical); + if (numeric) return index >= 0 && index == floor(index) && index < (double)v->v.bytes->len; + return scr_dyn_typed_array_proto_name(key) || (v->buffer && scr_dyn_buffer_proto_name(key)) || + scr_dyn_object_proto_name(key); +} + static bool scr_dyn_object_proto_key(const ScrDyn *target, const ScrStr *key) { return !target->null_proto && scr_dyn_object_proto_name(key); } @@ -7911,6 +7975,7 @@ bool scr_dyn_in(const ScrDyn *v, const ScrStr *key) { return out; } if (v->kind == SCR_DYN_PROXY) return scr_dyn_proxy_has(v, key); + if (v->kind == SCR_DYN_BYTES) return scr_dyn_bytes_in(v, key); if (scr_dyn_has_key(v, key)) return true; if (scr_exc_pending()) return false; if (v->kind == SCR_DYN_OBJ) return scr_dyn_builtin_inherited(v, key); diff --git a/tests/corpus/6407-dyn-bytes-in.ts b/tests/corpus/6407-dyn-bytes-in.ts new file mode 100644 index 000000000..e08eff8bc --- /dev/null +++ b/tests/corpus/6407-dyn-bytes-in.ts @@ -0,0 +1,28 @@ +// `in` on typed arrays, Buffers and DataViews held as `any` matches Node's. +// On a typed array a canonical numeric key answers by bounds (integral, +// not -0, below the length), and "1.5", "-0", "NaN", "Infinity", "01" and +// "1e0" answer false. Other keys name members of %TypedArray%.prototype, +// Buffer.prototype for a Buffer, each constructor's BYTES_PER_ELEMENT, or +// Object.prototype. A DataView has no indexed elements and answers its own +// prototype's members. + +const bytes: any = new Uint8Array([1, 2, 255]); +const floats: any = new Float64Array(2); +const buf: any = Buffer.from("ab"); +for (const [label, v] of [["u8", bytes], ["f64", floats], ["buffer", buf]] as [string, any][]) { + const keys: any[] = [0, 1, 2, 3, -1, "0", "1.5", "-0", "length", "byteLength", "buffer", "map", "set", "toString", "nope", "write"]; + console.log(label, keys.map((k) => `${String(k)}:${k in v}`).join(" ")); +} +const k: any = JSON.parse("1"); +console.log("computed", k in bytes, "length" in bytes, "map" in bytes); +const view: any = new DataView(new ArrayBuffer(4)); +console.log("dataview", [0, "getInt8", "setUint16", "byteLength", "buffer", "length", "map", "toString"].map((k) => `${String(k)}:${k in view}`).join(" ")); +const clamped: any = new Uint8ClampedArray(3); + +console.log("clamped", 2 in clamped, 3 in clamped, "BYTES_PER_ELEMENT" in clamped); +const sub: any = new Uint8Array([1, 2, 3, 4]).subarray(1, 3); +console.log("subarray", 0 in sub, 1 in sub, 2 in sub); +console.log("hasOwn", Object.hasOwn(bytes, 0), Object.hasOwn(bytes, 3), Object.hasOwn(bytes, "length"), bytes.hasOwnProperty(1)); +console.log("symbol", Symbol.iterator in bytes); +const empty: any = new Uint8Array(0); +console.log("empty", 0 in empty, "Infinity" in bytes, "NaN" in bytes, "1e0" in bytes, "01" in bytes); diff --git a/tests/corpus/6408-dyn-bytes-in-js.js b/tests/corpus/6408-dyn-bytes-in-js.js new file mode 100644 index 000000000..9677c094b --- /dev/null +++ b/tests/corpus/6408-dyn-bytes-in-js.js @@ -0,0 +1,35 @@ +// In JavaScript sources, `in` on typed arrays, Buffers and DataViews +// matches Node's. On a typed array a canonical numeric key answers by +// bounds (integral, not -0, below the length), and "1.5", "-0", "NaN", +// "Infinity", "01" and "1e0" answer false. Other keys name members of +// %TypedArray%.prototype, Buffer.prototype for a Buffer, each +// constructor's BYTES_PER_ELEMENT, or Object.prototype. A DataView has no +// indexed elements and answers its own prototype's members. + +function dyn(value) { + return value; +} + +const bytes = dyn(new Uint8Array([1, 2, 255])); +const floats = dyn(new Float64Array(2)); +const buf = dyn(Buffer.from("ab")); +function row(label, v) { + const keys = [0, 1, 2, 3, -1, "0", "1.5", "-0", "length", "byteLength", "buffer", "map", "set", "toString", "nope", "write"]; + console.log(label, keys.map((k) => `${String(k)}:${k in v}`).join(" ")); +} +row("u8", bytes); +row("f64", floats); +row("buffer", buf); +const k = dyn(JSON.parse("1")); +console.log("computed", k in bytes, "length" in bytes, "map" in bytes); +const view = dyn(new DataView(new ArrayBuffer(4))); +console.log("dataview", [0, "getInt8", "setUint16", "byteLength", "buffer", "length", "map", "toString"].map((k) => `${String(k)}:${k in view}`).join(" ")); +const clamped = dyn(new Uint8ClampedArray(3)); + +console.log("clamped", 2 in clamped, 3 in clamped, "BYTES_PER_ELEMENT" in clamped); +const sub = dyn(new Uint8Array([1, 2, 3, 4]).subarray(1, 3)); +console.log("subarray", 0 in sub, 1 in sub, 2 in sub); +console.log("hasOwn", Object.hasOwn(bytes, 0), Object.hasOwn(bytes, 3), Object.hasOwn(bytes, "length"), bytes.hasOwnProperty(1)); +console.log("symbol", Symbol.iterator in bytes); +const empty = dyn(new Uint8Array(0)); +console.log("empty", 0 in empty, "Infinity" in bytes, "NaN" in bytes, "1e0" in bytes, "01" in bytes); diff --git a/tests/harness/corpus-costs/6407-dyn-bytes-in.ts.json b/tests/harness/corpus-costs/6407-dyn-bytes-in.ts.json new file mode 100644 index 000000000..4b769b3a1 --- /dev/null +++ b/tests/harness/corpus-costs/6407-dyn-bytes-in.ts.json @@ -0,0 +1 @@ +{ "plain": 3590, "san": 4401 } diff --git a/tests/harness/corpus-costs/6408-dyn-bytes-in-js.js.json b/tests/harness/corpus-costs/6408-dyn-bytes-in-js.js.json new file mode 100644 index 000000000..12ffbeef1 --- /dev/null +++ b/tests/harness/corpus-costs/6408-dyn-bytes-in-js.js.json @@ -0,0 +1 @@ +{ "plain": 3076, "san": 4009 } From d321ea578033b442c7f005261d0dbe444c743cbf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:52:46 -0500 Subject: [PATCH 69/85] Leave the error marker out of own property names Object.getOwnPropertyNames, Reflect.ownKeys and Object.getOwnPropertyDescriptors on errors held as `any` or `unknown` listed "%error", the key that marks the checked-dynamic error encoding. Node lists only the error's properties. The marker is now left out of own property names; the runtime's own checks still read it. --- .../6401-dyn-error-own-property-names.ts.json | 6 +++ ...02-dyn-error-own-property-names-js.js.json | 6 +++ packages/runtime/src/scr_json.c | 15 ++++++- .../6401-dyn-error-own-property-names.ts | 44 +++++++++++++++++++ .../6402-dyn-error-own-property-names-js.js | 44 +++++++++++++++++++ .../6401-dyn-error-own-property-names.ts.json | 1 + ...02-dyn-error-own-property-names-js.js.json | 1 + 7 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-dyn-error-own-property-names.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-dyn-error-own-property-names-js.js.json create mode 100644 tests/corpus/6401-dyn-error-own-property-names.ts create mode 100644 tests/corpus/6402-dyn-error-own-property-names-js.js create mode 100644 tests/harness/corpus-costs/6401-dyn-error-own-property-names.ts.json create mode 100644 tests/harness/corpus-costs/6402-dyn-error-own-property-names-js.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-dyn-error-own-property-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-dyn-error-own-property-names.ts.json new file mode 100644 index 000000000..cf96c4f96 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6401-dyn-error-own-property-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6401-dyn-error-own-property-names.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-dyn-error-own-property-names-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-dyn-error-own-property-names-js.js.json new file mode 100644 index 000000000..49cf88884 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6402-dyn-error-own-property-names-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6402-dyn-error-own-property-names-js.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index aa331f617..e41c2c37d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -11478,7 +11478,20 @@ ScrDyn *scr_dyn_get_own_property_names(const ScrDyn *value) { scr_dyn_release(table); return out; } - if (value->kind == SCR_DYN_OBJ) return scr_dyn_obj_own_keys(value); + if (value->kind == SCR_DYN_OBJ) { + ScrDyn *keys = scr_dyn_obj_own_keys(value); + if (!scr_dyn_obj_get(value, "%error", 6)) return keys; + /* The checked-dynamic tree's error marker is not a property. */ + ScrDyn *names = scr_dyn_new_arr(); + scr_dyn_arr_reserve(names, keys->v.arr.len); + for (size_t i = 0; i < keys->v.arr.len; i++) { + const ScrStr *key = keys->v.arr.items[i]->v.str; + if (key->len == 6 && memcmp(key->data, "%error", 6) == 0) continue; + scr_dyn_arr_push(names, scr_dyn_retain(keys->v.arr.items[i])); + } + scr_dyn_release(keys); + return names; + } if (value->kind == SCR_DYN_ARR || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BYTES) { ScrDyn *keys = scr_dyn_new_arr(); size_t length = value->kind == SCR_DYN_ARR ? value->v.arr.len : diff --git a/tests/corpus/6401-dyn-error-own-property-names.ts b/tests/corpus/6401-dyn-error-own-property-names.ts new file mode 100644 index 000000000..1020cf014 --- /dev/null +++ b/tests/corpus/6401-dyn-error-own-property-names.ts @@ -0,0 +1,44 @@ +// The own property names of errors held as `any` or `unknown` match +// Node's: Object.getOwnPropertyNames, Reflect.ownKeys and +// Object.getOwnPropertyDescriptors list `stack`, `message` and the +// properties the error has, in order, and nothing else. + +class PlainSubclass extends Error {} + +function dyn(value: unknown): any { + return value; +} + +function names(label: string, error: any): void { + console.log( + label, + "|", + Object.getOwnPropertyNames(error).join(","), + "|", + Reflect.ownKeys(error).map(String).join(","), + "|", + Object.keys(Object.getOwnPropertyDescriptors(error)).join(","), + ); +} + +names("error", dyn(new Error("plain"))); +names("type error", dyn(new TypeError("bad thing"))); +names("no message", dyn(new RangeError())); +names("cause", dyn(new Error("outer", { cause: new Error("inner") }))); +names("subclass", dyn(new PlainSubclass("sub"))); + +const unknownError: unknown = new SyntaxError("unexpected"); +names("unknown", unknownError); + +const coded = dyn(new Error("with code")); +coded.code = "E_CODE"; +coded.detail = 1; +names("own properties", coded); + +try { + throw new URIError("thrown"); +} catch (error) { + names("caught", error); +} + +console.log("marker", Object.getOwnPropertyNames(dyn(new Error("x"))).some((name) => name.startsWith("%"))); diff --git a/tests/corpus/6402-dyn-error-own-property-names-js.js b/tests/corpus/6402-dyn-error-own-property-names-js.js new file mode 100644 index 000000000..6f6ca7fb7 --- /dev/null +++ b/tests/corpus/6402-dyn-error-own-property-names-js.js @@ -0,0 +1,44 @@ +// In JavaScript sources, the own property names of errors match +// Node's: Object.getOwnPropertyNames, Reflect.ownKeys and +// Object.getOwnPropertyDescriptors list `stack`, `message` and the +// properties the error has, in order, and nothing else. + +class PlainSubclass extends Error {} + +function dyn(value) { + return value; +} + +function names(label, error) { + console.log( + label, + "|", + Object.getOwnPropertyNames(error).join(","), + "|", + Reflect.ownKeys(error).map(String).join(","), + "|", + Object.keys(Object.getOwnPropertyDescriptors(error)).join(","), + ); +} + +names("error", dyn(new Error("plain"))); +names("type error", dyn(new TypeError("bad thing"))); +names("no message", dyn(new RangeError())); +names("cause", dyn(new Error("outer", { cause: new Error("inner") }))); +names("subclass", dyn(new PlainSubclass("sub"))); + +const unknownError = new SyntaxError("unexpected"); +names("unknown", unknownError); + +const coded = dyn(new Error("with code")); +coded.code = "E_CODE"; +coded.detail = 1; +names("own properties", coded); + +try { + throw new URIError("thrown"); +} catch (error) { + names("caught", error); +} + +console.log("marker", Object.getOwnPropertyNames(dyn(new Error("x"))).some((name) => name.startsWith("%"))); diff --git a/tests/harness/corpus-costs/6401-dyn-error-own-property-names.ts.json b/tests/harness/corpus-costs/6401-dyn-error-own-property-names.ts.json new file mode 100644 index 000000000..f85c4901a --- /dev/null +++ b/tests/harness/corpus-costs/6401-dyn-error-own-property-names.ts.json @@ -0,0 +1 @@ +{ "plain": 3175, "san": 4362 } diff --git a/tests/harness/corpus-costs/6402-dyn-error-own-property-names-js.js.json b/tests/harness/corpus-costs/6402-dyn-error-own-property-names-js.js.json new file mode 100644 index 000000000..55012b442 --- /dev/null +++ b/tests/harness/corpus-costs/6402-dyn-error-own-property-names-js.js.json @@ -0,0 +1 @@ +{ "plain": 3056, "san": 4620 } From b690f8f8f493d63231dfb752a31a913a3013be1b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 23:56:22 -0500 Subject: [PATCH 70/85] Count destructuring and for-of writes of a factory's parameter A factory's parameter counts as the argument it was called with only while the factory can't change it. The check saw assignments and updates of the name, but not a write through a destructuring pattern (`[body] = ...`, `({ body } = ...)`) or a for-in or for-of target, so a factory could replace its callback with one that reads `this` and the call still compiled without the module object. The parameter now counts as changed by any assignment, update, destructuring or for-in/for-of target that writes it, by a declaration of the same name, by a write to an element of `arguments`, and by a call of `eval`. --- .../frontend/lowering/module-member-this.ts | 70 ++++++++++++++++--- tests/harness/errors.test.ts | 66 +++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/module-member-this.ts b/packages/compiler/src/frontend/lowering/module-member-this.ts index faa1341d8..592136bec 100644 --- a/packages/compiler/src/frontend/lowering/module-member-this.ts +++ b/packages/compiler/src/frontend/lowering/module-member-this.ts @@ -106,29 +106,83 @@ function functionOfName( return { fn: null }; } -/** Whether `fn` (with its nested functions) assigns the binding `symbol`. */ -function assigns(lowerer: Lowerer, fn: ts.Node, symbol: ts.Symbol): boolean { +/** + * Whether `fn` (with its nested functions) may change what its parameter + * `param` holds: an assignment, update, destructuring or for-in/for-of + * target that writes it, a declaration of the same name, a write to an + * element of `arguments` (which aliases parameters outside strict mode), or + * a call of `eval`. + */ +function mayRebind(lowerer: Lowerer, fn: ts.Node, param: ts.Identifier): boolean { + const symbol = lowerer.checker.getSymbolAtLocation(param); + const refersTo = (node: ts.Node): boolean => + ts.isIdentifier(node) && + (node.text === param.text || lowerer.checker.getSymbolAtLocation(node) === symbol); + // Whether writing `target` writes the parameter: the name itself, or the + // name inside a destructuring pattern. + const writes = (target: ts.Node): boolean => { + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (refersTo(target)) return true; + if ( + ts.isElementAccessExpression(target) && + ts.isIdentifier(target.expression) && + target.expression.text === "arguments" + ) + return true; + if (ts.isArrayLiteralExpression(target)) return target.elements.nodes.some(writes); + if (ts.isObjectLiteralExpression(target)) + return target.properties.nodes.some((p) => + ts.isShorthandPropertyAssignment(p) + ? refersTo(p.name) + : ts.isPropertyAssignment(p) + ? writes(p.initializer) + : ts.isSpreadAssignment(p) && writes(p.expression), + ); + if (ts.isSpreadElement(target)) return writes(target.expression); + // A default in a pattern: `[body = other] = values`. + if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.EqualsToken) + return writes(target.left); + return false; + }; + const declaresName = (node: ts.Node): boolean => { + const name = (node as { name?: ts.Node }).name; + return name !== undefined && ts.isIdentifier(name) && name.text === param.text; + }; let found = false; - const isTarget = (node: ts.Node): boolean => - ts.isIdentifier(node) && lowerer.checker.getSymbolAtLocation(node) === symbol; const visit = (node: ts.Node): void => { if (found) return; if ( (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && - isTarget(node.left)) || + writes(node.left)) || ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken) && - isTarget(node.operand)) + writes(node.operand)) || + ((ts.isForOfStatement(node) || ts.isForInStatement(node)) && + !ts.isVariableDeclarationList(node.initializer) && + writes(node.initializer)) || + ((ts.isVariableDeclaration(node) || + ts.isBindingElement(node) || + ts.isParameter(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node)) && + declaresName(node)) || + (ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "eval") ) { found = true; return; } node.forEachChild(visit); }; - fn.forEachChild(visit); + // The function's own parameters declare the name; their defaults can write it. + const params = (fn as { parameters?: ts.NodeArray }).parameters; + for (const p of params?.nodes ?? []) if (p.initializer) visit(p.initializer); + const body = (fn as { body?: ts.Node }).body; + if (body !== undefined) visit(body); return found; } @@ -178,7 +232,7 @@ function ignoresThis( const symbol = lowerer.checker.getSymbolAtLocation(param.name); if (arg === undefined || ts.isSpreadElement(arg) || symbol === undefined) return false; // The parameter holds the argument for as long as the function runs. - if (assigns(lowerer, fn, symbol)) return false; + if (mayRebind(lowerer, fn, param.name)) return false; inner.set(symbol, { expr: arg, bindings }); } const returned = returnedExpressions(fn); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index b2d5358b3..eea541912 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1260,6 +1260,72 @@ const wrap = function (body) { }; export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that replaces its parameter as a destructuring", + "namespace-member-forwards-destructured", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + [body] = [function (s) { return this.tag + s; }]; + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that replaces its parameter as a object destructuring", + "namespace-member-forwards-object-destructured", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + ({ body } = { body: function (s) { return this.tag + s; } }); + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that replaces its parameter as a for-of target", + "namespace-member-forwards-for-of", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + for (body of [function (s) { return this.tag + s; }]); + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } `, }, "2\n", From 59dd6d17422f1d00a443a41c9b82daed70f017cd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:02:49 -0500 Subject: [PATCH 71/85] Read member aliases only while the binding holds the exported value A member export resolves to its binding only when the binding is declared before the write and never reassigned, so a consumer reads the value the member stored. A module that re-exports another module's exports through module.exports = require("./x") now counts its own writes after the re-exported module's: a member it adds resolves, and a member both modules write stays refused. The exports rebinding refusal and its test now cover the form that stays refused, exports = value without module.exports. --- .../frontend/lowering/cjs-exports-object.ts | 73 ++++++++++++++----- .../src/frontend/lowering/lower-stmts.ts | 2 +- .../6431-cjs-exports-rebinding/main.cjs.json | 1 + .../cjs-member-alias-writes/main.ts.json | 6 ++ .../6431-cjs-exports-rebinding/extended.cjs | 3 + .../6431-cjs-exports-rebinding/main.cjs | 3 + .../cjs-member-alias-writes/main.ts | 6 ++ .../node_modules/metrics/index.d.ts | 6 ++ .../node_modules/metrics/index.js | 3 + .../node_modules/metrics/lib.js | 8 ++ .../node_modules/metrics/package.json | 6 ++ .../cjs-member-alias-writes/main.ts.txt | 13 ++++ tests/harness/errors.test.ts | 4 +- 13 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/cjs-member-alias-writes/main.ts.json create mode 100644 tests/corpus/6431-cjs-exports-rebinding/extended.cjs create mode 100644 tests/diagnostics/cjs-member-alias-writes/main.ts create mode 100644 tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.d.ts create mode 100644 tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.js create mode 100644 tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/lib.js create mode 100644 tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/package.json create mode 100644 tests/harness/__snapshots__/cjs-member-alias-writes/main.ts.txt diff --git a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts index 5ff34a326..f06dde5a2 100644 --- a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts +++ b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts @@ -33,6 +33,7 @@ import { } from "../program.js"; import { cjsWholeExportValueOf, inlineProgramRequireOf } from "./cjs-inline-require.js"; import { isCjsModuleGlobal } from "./lower-node-module.js"; +import { bindingNeverReassigned } from "./binding-analysis.js"; import { DYN, type IrExpr, type IrGlobal } from "../../ir/ir.js"; /** True for the CommonJS wrapper's `exports` binding (not shadowed by any @@ -492,32 +493,68 @@ function memberAliasIn( name: string, depth: number, ): ts.Expression | null { + const member = memberWritesIn(lowerer, sf, name, depth); + // One write: a member written again would be a runtime slot. + return member !== null && member.writes === 1 ? member.value : null; +} + +/** The writes of member `name` in `sf` and in the modules it re-exports + * through `module.exports = require("./x")`, which share its exports + * object. `value` is the last write's value when the use site can read + * it again. Null when the module does not export a value whole. */ +function memberWritesIn( + lowerer: Lowerer, + sf: ts.SourceFile, + name: string, + depth: number, +): { value: ts.Expression | null; writes: number } | null { if (depth > 32 || sf.isDeclarationFile || !isCjsJsFile(sf, lowerer.program)) return null; const whole = cjsWholeExportValueOf(sf); if (whole === null) return null; - const inner = inlineProgramRequireOf(lowerer, whole.rhs); - if (inner !== null) return memberAliasIn(lowerer, inner.dep, name, depth + 1); - let found: ts.Expression | null = null; + let value: ts.Expression | null = null; let writes = 0; + const inner = inlineProgramRequireOf(lowerer, whole.rhs); + if (inner !== null) { + // The re-exported module runs first; this module's own writes follow. + const dep = memberWritesIn(lowerer, inner.dep, name, depth + 1); + if (dep === null) return null; + ({ value, writes } = dep); + } for (const stmt of sf.statements.nodes) { const cjs = cjsExportAssignmentOf(stmt); if (cjs?.kind !== "member" || !ts.isIdentifier(cjs.name) || cjs.name.text !== name) continue; writes++; if (cjsExportDiscardReason(stmt) !== null) return null; - let value: ts.Expression = cjs.value; - while (ts.isParenthesizedExpression(value)) value = value.expression; - // A binding of the module, or a primitive literal: reading either - // again at the use site observes the member's value. - if ( - ts.isIdentifier(value) || - ts.isStringLiteral(value) || - ts.isNumericLiteral(value) || - ts.isNoSubstitutionTemplateLiteral(value) || - value.kind === ts.SyntaxKind.TrueKeyword || - value.kind === ts.SyntaxKind.FalseKeyword - ) - found = value; + let written: ts.Expression = cjs.value; + while (ts.isParenthesizedExpression(written)) written = written.expression; + value = rereadableMemberValue(lowerer, stmt, written) ? written : null; } - // One write: a member written again would be a runtime slot. - return writes === 1 ? found : null; + return { value, writes }; +} + +/** True when reading `value` again at the use site observes what the + * member write `stmt` stored: a primitive literal, or a module binding + * declared before the write and never reassigned. */ +function rereadableMemberValue( + lowerer: Lowerer, + stmt: ts.Statement, + value: ts.Expression, +): boolean { + if ( + ts.isStringLiteral(value) || + ts.isNumericLiteral(value) || + ts.isNoSubstitutionTemplateLiteral(value) || + value.kind === ts.SyntaxKind.TrueKeyword || + value.kind === ts.SyntaxKind.FalseKeyword + ) + return true; + if (!ts.isIdentifier(value)) return false; + const sym = lowerer.resolveValueSymbol(value); + if (sym === null || sym.declarations.length !== 1) return false; + const decl = sym.declarations[0]!; + if (decl.getSourceFile() !== stmt.getSourceFile()) return false; + // A function declaration is initialized when the module starts; any + // other binding must be initialized before the write stores it. + if (!ts.isFunctionDeclaration(decl) && decl.end > stmt.pos) return false; + return bindingNeverReassigned(lowerer, sym, decl); } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 5fddb31d5..c9392db8c 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -6922,7 +6922,7 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm lowerer.unsupported( "SC1090", expr.left, - "rebinding 'exports' (exports = module.exports = value): members attached through 'exports' afterwards are properties of that value, which the static export table does not model — write module.exports. = … and module.exports = value", + "rebinding 'exports' without module.exports (exports = value): members attached through 'exports' afterwards are properties of that value, which module.exports does not hold — write exports = module.exports = value", ); } const cjs = cjsExportAssignmentOf(stmtNode); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json index 25277f4a5..8fc4a2acc 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6431-cjs-exports-rebinding/main.cjs.json @@ -1,6 +1,7 @@ { "order": [ "/tests/corpus/6431-cjs-exports-rebinding/lib.cjs", + "/tests/corpus/6431-cjs-exports-rebinding/extended.cjs", "/tests/corpus/6431-cjs-exports-rebinding/main.cjs" ], "diags": [] diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/cjs-member-alias-writes/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/cjs-member-alias-writes/main.ts.json new file mode 100644 index 000000000..bd3ba799d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/cjs-member-alias-writes/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/cjs-member-alias-writes/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6431-cjs-exports-rebinding/extended.cjs b/tests/corpus/6431-cjs-exports-rebinding/extended.cjs new file mode 100644 index 000000000..d07438ee9 --- /dev/null +++ b/tests/corpus/6431-cjs-exports-rebinding/extended.cjs @@ -0,0 +1,3 @@ +"use strict"; +module.exports = require("./lib.cjs"); +module.exports.extra = "added"; diff --git a/tests/corpus/6431-cjs-exports-rebinding/main.cjs b/tests/corpus/6431-cjs-exports-rebinding/main.cjs index 5c52ff687..cfeec742b 100644 --- a/tests/corpus/6431-cjs-exports-rebinding/main.cjs +++ b/tests/corpus/6431-cjs-exports-rebinding/main.cjs @@ -4,3 +4,6 @@ const format = require("./lib.cjs"); const { shout, version } = require("./lib.cjs"); console.log(format("a"), shout("b"), version); console.log(typeof format, typeof shout); +// A module that re-exports another module's exports and adds a member. +const { extra, shout: loud } = require("./extended.cjs"); +console.log(extra, loud("c")); diff --git a/tests/diagnostics/cjs-member-alias-writes/main.ts b/tests/diagnostics/cjs-member-alias-writes/main.ts new file mode 100644 index 000000000..d3723c41a --- /dev/null +++ b/tests/diagnostics/cjs-member-alias-writes/main.ts @@ -0,0 +1,6 @@ +// A member export is read through its binding only when that binding still +// holds the exported value: a member that holds a reassigned binding, or one +// that both a module and its re-exporter write, is refused. +import { counter, version } from "metrics"; +console.log(counter); +console.log(version); diff --git a/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.d.ts b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.d.ts new file mode 100644 index 000000000..c9c639246 --- /dev/null +++ b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.d.ts @@ -0,0 +1,6 @@ +declare function metrics(): string; +declare namespace metrics { + const counter: number; + const version: string; +} +export = metrics; diff --git a/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.js b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.js new file mode 100644 index 000000000..7449f0e8a --- /dev/null +++ b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/index.js @@ -0,0 +1,3 @@ +"use strict"; +module.exports = require("./lib.js"); +module.exports.version = "2.0.0"; diff --git a/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/lib.js b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/lib.js new file mode 100644 index 000000000..913fe8bd1 --- /dev/null +++ b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/lib.js @@ -0,0 +1,8 @@ +"use strict"; +let counter = 1; +exports = module.exports = function metrics() { + return "metrics"; +}; +exports.counter = counter; +exports.version = "1.0.0"; +counter = 5; diff --git a/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/package.json b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/package.json new file mode 100644 index 000000000..f97fe551f --- /dev/null +++ b/tests/diagnostics/cjs-member-alias-writes/node_modules/metrics/package.json @@ -0,0 +1,6 @@ +{ + "name": "metrics", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/harness/__snapshots__/cjs-member-alias-writes/main.ts.txt b/tests/harness/__snapshots__/cjs-member-alias-writes/main.ts.txt new file mode 100644 index 000000000..1682e9bfb --- /dev/null +++ b/tests/harness/__snapshots__/cjs-member-alias-writes/main.ts.txt @@ -0,0 +1,13 @@ +cjs-member-alias-writes/main.ts:5:13 - error SC1090: the reference to 'counter' (a binding form with no lowering) is not supported yet + + 4 | import { counter, version } from "metrics"; + 5 | console.log(counter); + | ^~~~~~~ + 6 | console.log(version); + +cjs-member-alias-writes/main.ts:6:13 - error SC1090: the reference to 'version' (a binding form with no lowering) is not supported yet + + 5 | console.log(counter); + 6 | console.log(version); + | ^~~~~~~ + 7 | \ No newline at end of file diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 26dfd3a5c..8c60a1f81 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1174,7 +1174,7 @@ console.log(keys(exports)); [ "exports-rebinding", `console.log("before"); -exports = module.exports = function hash(x) { +exports = function hash(x) { return x; }; exports.extra = 1; @@ -1182,7 +1182,7 @@ exports.extra = 1; {}, "before\n", 2, - "rebinding 'exports' \\(exports = module\\.exports = value\\)", + "rebinding 'exports' without module\\.exports \\(exports = value\\)", ], [ "require-computed", From 226ba9672f1e5109edc47dfb7d4ba8e9e7dd64df Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:05:09 -0500 Subject: [PATCH 72/85] Borrow typed-array locals read from fields and parameters A local initialized from a typed-array field or parameter, such as `const chars = this.chars`, took a reference and released it at the end of its block, like any owned binding. Class instances, records, arrays and strings already borrow such a projection when the rest of the block writes none of the locations it reads, and walk and rebound parameters use the same test. Typed arrays are one plain pointer as well, so they now take the same paths: the field or the caller's binding keeps the array alive while the local reads it, and a block that may replace the field still retains its own reference. --- packages/compiler/src/backend/llvm/emitter.ts | 5 +- .../test/bytes-field-borrow-emission.test.ts | 94 +++++++++++++++++++ .../5891-typed-array-field-borrows.ts.json | 6 ++ .../corpus/5891-typed-array-field-borrows.ts | 68 ++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/bytes-field-borrow-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json create mode 100644 tests/corpus/5891-typed-array-field-borrows.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 64a535027..1d745b981 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5385,8 +5385,8 @@ export class LlEmitter { } /** A reference held as one plain pointer that emitReadReceiver can read - * without owning: class instances, records, arrays, strings and - * nullable-pointer unions. Tagged union boxes, closures and dynamic + * without owning: class instances, records, arrays, strings, typed arrays + * and nullable-pointer unions. Tagged union boxes, closures and dynamic * values keep ordinary ownership. */ plainReference(type: IrType): boolean { switch (type.kind) { @@ -5394,6 +5394,7 @@ export class LlEmitter { case "record": case "array": case "string": + case "bytes": return true; case "union": return this.nullableUnions.has(type.unionId); diff --git a/packages/compiler/test/bytes-field-borrow-emission.test.ts b/packages/compiler/test/bytes-field-borrow-emission.test.ts new file mode 100644 index 000000000..168cd78ac --- /dev/null +++ b/packages/compiler/test/bytes-field-borrow-emission.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "vitest"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { + F64, + VOID, + bytesOf, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, +} from "../src/ir/ir.js"; +import { validateModule } from "../src/ir/validate.js"; + +const loc = { file: "bytes-field-borrow.ts", start: 0, end: 0 }; +const units = bytesOf("u16"); +const scanner = { kind: "object" as const, className: "Scanner" }; +const self = (): IrExpr => ({ kind: "varRef", localId: "this", type: scanner, loc }); +const chars = (): IrExpr => ({ + kind: "fieldGet", + obj: self(), + className: "Scanner", + field: "chars", + type: units, + loc, +}); +const local = (): IrExpr => ({ kind: "varRef", localId: "c", type: units, loc }); +const read = (index: number): IrExpr => ({ + kind: "bytesIntrinsic", + method: "get", + receiver: local(), + args: [{ kind: "numLit", value: index, type: F64, loc }], + type: F64, + loc, +}); + +/** `const c = this.chars; ...between; return c[0] + c[1]`. */ +function method(name: string, between: IrStmt[]): IrFunction { + return { + name, + params: [{ localId: "this", name: "this", type: scanner }], + returnType: F64, + locals: [ + { id: "this", name: "this", type: scanner, mutable: false }, + { id: "c", name: "c", type: units, mutable: false }, + ], + body: [ + { kind: "varDecl", localId: "c", init: chars(), loc }, + ...between, + { + kind: "return", + value: { kind: "bin", op: "+", left: read(0), right: read(1), type: F64 }, + loc, + }, + ], + loc, + } as IrFunction; +} + +test("a typed-array local read from a field the rest of its block never writes borrows it", () => { + const replace: IrStmt = { + kind: "fieldSet", + obj: self(), + className: "Scanner", + field: "chars", + value: { + kind: "bytesNew", + source: { kind: "numLit", value: 4, type: F64, loc }, + type: units, + loc, + }, + loc, + }; + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + functions: [ + { name: "main", params: [], returnType: VOID, locals: [], body: [], loc }, + method("%Scanner.peek", []), + method("%Scanner.swap", [replace]), + ], + classes: [{ name: "Scanner", fields: [{ name: "chars", type: units }], methods: [], loc }], + }; + expect(validateModule(mod)).toEqual([]); + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + const body = (name: string): string => + new RegExp(`^define [^\\n]*@sc_bf__x25_Scanner_${name}\\b[^]*?^}`, "m").exec(llvm)?.[0] ?? ""; + expect(body("peek")).not.toBe(""); + // The field keeps the array alive while the borrowed local reads it. + expect(body("peek")).not.toContain("bytes_retain"); + expect(body("peek")).not.toContain("bytes_release"); + // Replacing the field could free the array the local holds: it keeps a reference. + expect(body("swap")).toContain("call ptr @scr_bytes_retain_v("); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json new file mode 100644 index 000000000..efd0044d0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5891-typed-array-field-borrows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5891-typed-array-field-borrows.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5891-typed-array-field-borrows.ts b/tests/corpus/5891-typed-array-field-borrows.ts new file mode 100644 index 000000000..cdd315191 --- /dev/null +++ b/tests/corpus/5891-typed-array-field-borrows.ts @@ -0,0 +1,68 @@ +// Typed-array locals read from fields and parameters: reads while nothing +// replaces the field, the field replaced directly or by a callee while the +// local is in use, rebound parameters, views and detached-length reads. + +class Scanner { + chars: Uint16Array; + pos = 0; + constructor(text: string) { + this.chars = Scanner.units(text); + } + static units(text: string): Uint16Array { + const out = new Uint16Array(text.length + 2); + for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i); + return out; + } + sum(): number { + const chars = this.chars; + let total = 0; + for (let i = 0; i < chars.length; i++) total += chars[i]!; + return total; + } + replaceThenRead(): number { + const chars = this.chars; + this.chars = Scanner.units("zz"); + return chars[0]! * 1000 + this.chars[0]!; + } + reset(text: string): void { + this.chars = Scanner.units(text); + } + readAcrossReset(): string { + const chars = this.chars; + const before = chars[0]!; + this.reset("q"); + return before + ":" + chars[1] + ":" + this.chars[0] + ":" + chars.length; + } +} + +function firstOf(units: Uint16Array, fallback: Uint16Array): number { + if (units.length === 0) units = fallback; + let n = 0; + while (n < units.length && units[n] !== 0) n++; + return n; +} + +function walk(a: Uint8Array): number { + let total = 0; + while (a.length > 1) { + total += a[0]!; + a = a.subarray(1); + } + return total + a.length; +} + +const s = new Scanner("hello"); +console.log(s.sum(), s.replaceThenRead(), s.sum()); +const t = new Scanner("abc"); +console.log(t.readAcrossReset(), t.sum()); +console.log(firstOf(new Uint16Array(0), Scanner.units("xyz")), firstOf(Scanner.units("ab"), new Uint16Array(1))); +console.log(walk(new Uint8Array([1, 2, 3, 4, 5])), walk(new Uint8Array(0))); + +const holders: Scanner[] = []; +for (let i = 0; i < 50; i++) holders.push(new Scanner("item" + i)); +let acc = 0; +for (const h of holders) { + acc += h.sum(); + acc += h.replaceThenRead(); +} +console.log(acc); From bae0570c2c78502c68c7d5eff46246755eacb8b1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:17:34 -0500 Subject: [PATCH 73/85] Format checked-dynamic arguments under %d and %i as Node does util.format's %d validated a checked-dynamic argument as a number and threw "expected number" for anything else, where Node prints Number(arg), and %i of such an argument had no lowering. Both now follow Node: a bigint prints as `${n}n`, a symbol as NaN, and any other value as Number(arg) under %d or parseInt(String(arg)) under %i. --- .../src/frontend/lowering/lower-inspect.ts | 82 ++++++++++++++++--- .../corpus/6356-format-integer-dyn.ts.json | 6 ++ tests/corpus/6356-format-integer-dyn.ts | 9 ++ .../6356-format-integer-dyn.ts.json | 1 + 4 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json create mode 100644 tests/corpus/6356-format-integer-dyn.ts create mode 100644 tests/harness/corpus-costs/6356-format-integer-dyn.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 245d1b676..6f01cee19 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -2011,6 +2011,47 @@ function formatOArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(depth, loc), loc); } +/** %d and %i over a checked-dynamic argument, as Node's formatWithOptions + * reads it: a bigint prints as `${n}n`, a symbol as NaN, and any other + * value as `number` (Number(arg) for %d, parseInt(String(arg)) for %i) + * formatted. The argument evaluates once. */ +function formatDynInteger( + lowerer: Lowerer, + value: IrExpr, + number: (held: IrExpr) => IrExpr, + loc: SrcLoc, +): IrExpr { + const held = lowerer.declareHiddenLocal("%formatArg", DYN); + const ref = (): IrExpr => varRef(held.id, DYN, loc); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: held.id, init: value, loc }], + result: { + kind: "ternary", + cond: { kind: "dynTest", test: "bigint", value: ref(), type: BOOL, loc }, + then: { + kind: "strConcat", + left: { kind: "libCall", fn: "dyn.toStringCoerce", args: [ref()], type: STRING, loc }, + right: strLit("n", loc), + type: STRING, + loc, + }, + else_: { + kind: "ternary", + cond: { kind: "dynTest", test: "symbol", value: ref(), type: BOOL, loc }, + then: strLit("NaN", loc), + else_: { kind: "libCall", fn: "insp.f64", args: [number(ref())], type: STRING, loc }, + type: STRING, + loc, + }, + type: STRING, + loc, + }, + type: STRING, + loc, + }; +} + /** %d and %i over the argument types Node formats without a number * conversion: a bigint prints as `${n}n` (formatBigInt) and a symbol as NaN; * null is Number(null) (0) under %d and parseInt("null") (NaN) under %i, and @@ -2141,19 +2182,20 @@ export function lowerFormatCall( }; } // A checked-dynamic argument (`console.log('... actual %d.', - // context.actual)` — test/common's exit report): VALIDATE it as a - // number (dynCheck) and format. A non-number dyn value throws the - // catchable TypeError where Node would print its ToNumber (NaN - // for objects) — it throws instead of returning a wrong result - // (SEMANTICS.md). + // context.actual)` — test/common's exit report): Number(arg). if (value.type.kind === "dyn") { - return { - kind: "libCall", - fn: "insp.f64", - args: [{ kind: "dynCheck", value, type: F64, loc }], - type: STRING, + return formatDynInteger( + lowerer, + value, + (held) => ({ + kind: "libCall", + fn: "dyn.numberConstructor", + args: [held], + type: F64, + loc, + }), loc, - }; + ); } lowerer.noLowering( `util.format %d of '${lowerer.fmt(value.type)}' values`, @@ -2185,6 +2227,24 @@ export function lowerFormatCall( }; return { kind: "libCall", fn: "insp.f64", args: [parsed], type: STRING, loc }; } + // parseInt(String(arg)) over a checked-dynamic argument. + if (value.type.kind === "dyn") { + return formatDynInteger( + lowerer, + value, + (held) => ({ + kind: "libCall", + fn: "num.parseInt", + args: [ + { kind: "libCall", fn: "dyn.toStringCoerce", args: [held], type: STRING, loc }, + numLit(0, loc), + ], + type: F64, + loc, + }), + loc, + ); + } lowerer.noLowering(`util.format %i of '${lowerer.fmt(value.type)}' values`, node); break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json new file mode 100644 index 000000000..6e1082a83 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6356-format-integer-dyn.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6356-format-integer-dyn.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6356-format-integer-dyn.ts b/tests/corpus/6356-format-integer-dyn.ts new file mode 100644 index 000000000..a4bd53ab0 --- /dev/null +++ b/tests/corpus/6356-format-integer-dyn.ts @@ -0,0 +1,9 @@ +// util.format's %d and %i over checked-dynamic arguments, as Node reads +// them: a bigint prints as `${n}n`, a symbol as NaN, and any other value +// as Number(arg) (%d) or parseInt(String(arg)) (%i). +import { format } from "node:util"; +const values: unknown[] = [{}, [5], [1, 2], "12px", " 0x1f", "", null, undefined, true, 3.9, -0, 10n, Symbol("s"), new Date(0), { valueOf: () => 7 }, NaN, "1e3"]; +for (const v of values) console.log(format("%d|%i", v, v)); +console.log(format("%d and %i", 2n ** 70n, -5n)); +const report: unknown = { actual: 3 }; +console.log("... actual %d.", (report as { actual: unknown }).actual); diff --git a/tests/harness/corpus-costs/6356-format-integer-dyn.ts.json b/tests/harness/corpus-costs/6356-format-integer-dyn.ts.json new file mode 100644 index 000000000..8bcdf4a18 --- /dev/null +++ b/tests/harness/corpus-costs/6356-format-integer-dyn.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2000 } From 88f2061f628975de1aeb09aff2031f2aa273e62d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:31:25 -0500 Subject: [PATCH 74/85] Refuse JSON and dynamic copies of object literals with accessors An object literal with get/set accessors keeps the accessors' closures in reserved slots of its record. Since function values became convertible to checked-dynamic values, these records passed the JSON and conversion checks: JSON.stringify omitted the getter's property, as it omits function-valued ones, and a copy held as `any` or `unknown` read the getter's property as undefined and listed the slot's internal name among its keys. Node invokes the getter in both cases. Records with accessor slots are now outside the JSON-safe and dyn-convertible domains, so JSON.stringify of such a value and its conversion to `unknown` or `any` are refused at compile time. A literal written directly into an `any` slot keeps its accessors and is unchanged. --- packages/compiler/src/ir/ir.ts | 6 ++++ .../object-literal-accessor-values.ts.json | 6 ++++ .../object-literal-accessor-values.ts | 31 +++++++++++++++++++ .../object-literal-accessor-values.ts.txt | 24 ++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json create mode 100644 tests/diagnostics/object-literal-accessor-values.ts create mode 100644 tests/harness/__snapshots__/object-literal-accessor-values.ts.txt diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b84c0594e..9a8f1121c 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3891,6 +3891,9 @@ function isJsonSafeAt( const shape = getRecord(t.shapeId); if (!shape) return false; if (recordTextCodecClass(shape) !== null) return false; + // Accessor slots are not function-valued properties: Node's + // JSON.stringify invokes the getter (see accessorSlotProp). + if (shapeHasAccessorSlots(shape)) return false; // The recursive knot: answer true and let the rest of the graph // decide (any unsafe constituent ends the traversal immediately). if (visiting.has(t.shapeId)) return true; @@ -4228,6 +4231,9 @@ function canBoxDynCompositeAt( const shape = getRecord(t.shapeId); if (!shape) return false; if (recordTextCodecClass(shape) !== null) return false; + // Accessor slots hold the getter and setter closures, not property + // values (see accessorSlotProp). + if (shapeHasAccessorSlots(shape)) return false; // Recursive shapes answer coinductively, like isJsonSafeType. if (visiting.has(t.shapeId)) return true; visiting.add(t.shapeId); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json new file mode 100644 index 000000000..08ae4c161 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/object-literal-accessor-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/object-literal-accessor-values.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/object-literal-accessor-values.ts b/tests/diagnostics/object-literal-accessor-values.ts new file mode 100644 index 000000000..107ab1f69 --- /dev/null +++ b/tests/diagnostics/object-literal-accessor-values.ts @@ -0,0 +1,31 @@ +// Object literals with get/set accessors keep their closures in reserved +// slots, so their typed values have no JSON or checked-dynamic form: Node's +// JSON.stringify and dynamic reads invoke the getter, which neither a typed +// serializer nor a converted copy could do. + +function hold(value: unknown): unknown { + return value; +} + +const counter = { + count: 1, + get doubled(): number { + return 2; + }, +}; + +// JSON.stringify of the typed value would omit the getter's property. +console.log(JSON.stringify(counter)); + +// Converting the typed value to `unknown` or `any` would lose the getter. +hold(counter); +const viaAny: any = counter; +console.log(viaAny); + +// A literal written into an `any` slot directly keeps its accessor. +const direct: any = { + get value(): number { + return 3; + }, +}; +console.log(direct.value); diff --git a/tests/harness/__snapshots__/object-literal-accessor-values.ts.txt b/tests/harness/__snapshots__/object-literal-accessor-values.ts.txt new file mode 100644 index 000000000..89bc092aa --- /dev/null +++ b/tests/harness/__snapshots__/object-literal-accessor-values.ts.txt @@ -0,0 +1,24 @@ +object-literal-accessor-values.ts:18:28 - error SC1090: JSON.stringify of '{ get doubled(): number; count: number }' values (only number, string, boolean, records, arrays, unions of those, and 'unknown' stringify) are not supported yet + + 17 | // JSON.stringify of the typed value would omit the getter's property. + 18 | console.log(JSON.stringify(counter)); + | ^~~~~~~ + 19 | + +object-literal-accessor-values.ts:21:6 - error SC1101: converting typed values to 'unknown' is not supported yet + + 20 | // Converting the typed value to `unknown` or `any` would lose the getter. + 21 | hold(counter); + | ^~~~~~~ + 22 | const viaAny: any = counter; + + hint: numbers, strings, booleans, JSON-safe records/arrays/unions (a deep copy), functions, program class instances, and supported native handles convert into 'unknown' slots; functions, exact-class round trips, and handles preserve identity, while this value's type has no dynamic representation yet + +object-literal-accessor-values.ts:22:21 - error SC1101: converting typed values to 'unknown' is not supported yet + + 21 | hold(counter); + 22 | const viaAny: any = counter; + | ^~~~~~~ + 23 | console.log(viaAny); + + hint: numbers, strings, booleans, JSON-safe records/arrays/unions (a deep copy), functions, program class instances, and supported native handles convert into 'unknown' slots; functions, exact-class round trips, and handles preserve identity, while this value's type has no dynamic representation yet \ No newline at end of file From a495dc35a635141f6987589bd8e9bd1a1ad43945 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 00:38:58 -0500 Subject: [PATCH 75/85] Run LeakSanitizer over the sanitized corpus on Linux CI The Linux test jobs set ASAN_OPTIONS=detect_leaks=0 for both flavors and left leak detection to the RC audit, which does not see runtime tables allocated with calloc. A worker thread's record-source table leaked that way, and only local runs reported it. The san shards' case-sharded corpus task now sets detect_leaks=1 and an LSAN_OPTIONS suppressions file, tests/harness/lsan.supp, which starts empty. Plain shards, the side queue, the Effect jobs and macOS, whose ASan has no LeakSanitizer, keep leak detection off. --- .github/workflows/ci.yml | 4 +++- scripts/ci-test-plan.mjs | 25 ++++++++++++++++++++++++- tests/harness/ci-test-plan.test.ts | 15 +++++++++++++++ tests/harness/lsan.supp | 4 ++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/harness/lsan.supp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f2a6d51d..8a726aee8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,7 +172,9 @@ jobs: SCRIPTC_SAN: ${{ matrix.flavor == 'san' && '1' || '' }} # Match macOS, where Apple ASan has no LeakSanitizer: the RC audit owns # leak detection, including intentional abandonment. Plain shards also - # build some sanitized probes, so this applies to both flavors. + # build some sanitized probes, so this applies to both flavors. The + # san shards' case-sharded corpus turns LeakSanitizer on + # (scripts/ci-test-plan.mjs, tests/harness/lsan.supp). ASAN_OPTIONS: detect_leaks=0 # Within-file partition for the large case-driven harnesses # (differential, llvm-differential, npm, server, Test262, coverage, and diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 063af2ab9..55eb65e72 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; // Case-sharded harnesses run in every job without Vitest file sharding. export const effectFiles = ["tests/harness/effect.test.ts", "tests/harness/effect4.test.ts"]; @@ -97,7 +98,28 @@ export const darwinFiles = [ "tests/harness/terminal-geometry.test.ts", ]; -export function ciTestPlan({ shard, flavor, workers = 4, suite = "full" }) { +// LeakSanitizer over the sanitized corpus on Linux (macOS ASan has none, and +// the workflow keeps it off everywhere else): a leak fails its program. The +// suppressions file lists intentional leaks, with the reason for each. +export const leakSuppressions = fileURLToPath( + new URL("../tests/harness/lsan.supp", import.meta.url), +); + +function corpusLeakEnv(flavor, platform) { + if (flavor !== "san" || platform !== "linux") return {}; + return { + ASAN_OPTIONS: "detect_leaks=1", + LSAN_OPTIONS: `suppressions=${leakSuppressions}:print_suppressions=0`, + }; +} + +export function ciTestPlan({ + shard, + flavor, + workers = 4, + suite = "full", + platform = process.platform, +}) { const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shard); if ( !match || @@ -194,6 +216,7 @@ export function ciTestPlan({ shard, flavor, workers = 4, suite = "full" }) { args: ["test", ...corpusFiles], env: { ...env, + ...corpusLeakEnv(flavor, platform), SCRIPTC_TEST_WORKERS: String(workers - sideWorkers), SCRIPTC_LLVM_TEST_MODE: "dev", }, diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index 3b8c6d4e1..2343407df 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -10,6 +10,7 @@ import { fileExclusions, caseShardedSideFiles, isolatedFiles, + leakSuppressions, testCosts, } from "../../scripts/ci-test-plan.mjs"; import { runCiTestPlan } from "../../scripts/ci-test.mjs"; @@ -66,6 +67,20 @@ test.for([1, 3, 5, 10, 20])( }, ); +test("LeakSanitizer runs over the sanitized corpus on Linux only", () => { + const plan = (flavor: "plain" | "san", platform: string) => + ciTestPlan({ shard: "1/20", flavor, workers: 4, platform }); + const linuxSan = plan("san", "linux").corpus.env; + expect(linuxSan.ASAN_OPTIONS).toBe("detect_leaks=1"); + expect(linuxSan.LSAN_OPTIONS).toBe(`suppressions=${leakSuppressions}:print_suppressions=0`); + expect(existsSync(leakSuppressions)).toBe(true); + for (const env of [plan("plain", "linux").corpus.env, plan("san", "darwin").corpus.env]) { + expect(env.ASAN_OPTIONS).toBeUndefined(); + expect(env.LSAN_OPTIONS).toBeUndefined(); + } + for (const task of plan("san", "linux").side) expect(task.env.ASAN_OPTIONS).toBeUndefined(); +}); + test("the side queue balances measured files across the twenty Linux shards", () => { for (const flavor of ["plain", "san"] as const) { const costs = testCosts[flavor] as Record; diff --git a/tests/harness/lsan.supp b/tests/harness/lsan.supp new file mode 100644 index 000000000..41125589f --- /dev/null +++ b/tests/harness/lsan.supp @@ -0,0 +1,4 @@ +# LeakSanitizer suppressions for the sanitized corpus on Linux CI +# (scripts/ci-test-plan.mjs). One `leak:` line per intentional +# leak, under a comment that gives the reason. Empty: no corpus program +# leaks. From ba8849a269784ccfe9fbcaebf3da51bbad9d4a4a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:11:36 -0500 Subject: [PATCH 76/85] Speed up native string character tests, optional-string switches, fromCharCode and long-string length `s.charAt(i) === "c"`, and `s[i] === "c"` where the index is proven in bounds, built a one-character string to compare with the literal. When the literal is one UTF-16 code unit, the comparison now reads `s.charCodeAt(i)` and compares the number. charCodeAt converts the index the same way charAt does, and an index out of range answers NaN, which equals no unit, as "" equals no one-unit literal. A switch on `s.charAt(i)` whose case tests are all one-unit literals switches on the code unit the same way. A switch on a `string | undefined` value with string literal cases, such as an array element or an optional field, lowered to a chain of union equalities. It now tests for undefined, which matches no case, and dispatches the string through the string switch: by byte length, then by the literal's leading bytes. `String.fromCharCode(a, b, ...)` with up to sixteen arguments passes the codes to the runtime in a stack array instead of a heap f64[], and up to 64 code units encode into a stack buffer instead of a heap scratch block. `.length` of a string that is not ASCII counts its UTF-16 units. The string index keeps four entries, so a program that read the lengths of more long texts than that in turn (the text of every source file) walked each whole string on every read. Lengths of non-ASCII strings longer than 64 bytes now go into a 1024-slot table keyed by address, allocated by the first length it records. A slot is dropped when the string is freed or reallocated and when an append changes it in place, which is where the index drops its facts. --- .../src/backend/llvm/emit-statements.ts | 101 +++++++++++++++++- .../src/backend/llvm/expr-primitives.ts | 44 +++++++- .../src/backend/llvm/lib-filesystem.ts | 24 +++++ .../corpus/5607-string-char-compare.ts.json | 6 ++ .../corpus/5608-string-char-switch.ts.json | 6 ++ .../5609-string-switch-optional.ts.json | 6 ++ .../5750-string-from-char-codes.ts.json | 6 ++ .../corpus/5751-string-length-cache.ts.json | 6 ++ packages/runtime/src/scr_lib.c | 15 ++- packages/runtime/src/scr_number.h | 2 + packages/runtime/src/scr_string.c | 49 ++++++++- tests/corpus/5607-string-char-compare.ts | 37 +++++++ tests/corpus/5608-string-char-switch.ts | 95 ++++++++++++++++ tests/corpus/5609-string-switch-optional.ts | 57 ++++++++++ tests/corpus/5750-string-from-char-codes.ts | 27 +++++ tests/corpus/5751-string-length-cache.ts | 36 +++++++ 16 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5607-string-char-compare.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5608-string-char-switch.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5609-string-switch-optional.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5750-string-from-char-codes.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5751-string-length-cache.ts.json create mode 100644 tests/corpus/5607-string-char-compare.ts create mode 100644 tests/corpus/5608-string-char-switch.ts create mode 100644 tests/corpus/5609-string-switch-optional.ts create mode 100644 tests/corpus/5750-string-from-char-codes.ts create mode 100644 tests/corpus/5751-string-length-cache.ts diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index e641f3ecd..01fa11035 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -1,6 +1,6 @@ /* LlEmitter's statement emission: the statement switch, try/catch, switch, loop copies, speculative writes and string self-concatenation. */ import { type LlEmitter, type LlScopeEntry, adoptingAwaitOf } from "./emitter.js"; -import { CAUGHT, type IrExpr, type IrStmt, STRING, isRefCounted } from "../../ir/ir.js"; +import { CAUGHT, F64, type IrExpr, type IrStmt, STRING, isRefCounted } from "../../ir/ir.js"; import { emitStringSliceSnapshot } from "./string-slices.js"; import { canDeferSplitPiece, @@ -1657,6 +1657,8 @@ export function emitTryCatch(emitter: LlEmitter, s: IrStmt & { kind: "tryCatch" * case, refcounted/boxed case-body locals are NULL-reset up front and * the scope-exit releases rely on NULL tolerance. */ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): void { + const unitSwitch = charAtUnitSwitch(s); + if (unitSwitch !== null) return emitSwitch(emitter, unitSwitch); const B = emitter.B; const discKind = s.disc.type.kind; if (discKind !== "f64" && discKind !== "string" && discKind !== "bool") { @@ -1680,7 +1682,31 @@ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): const caseLabels = s.cases.map(() => B.newLabel("sw.c")); const defaultIdx = s.cases.findIndex((c) => c.test === null); const fallback = defaultIdx >= 0 ? caseLabels[defaultIdx]! : end; - if (!emitLiteralSwitch(emitter, disc, s.disc, s.cases, caseLabels, fallback)) { + const nullableString = nullableStringSwitch(emitter, s); + if (nullableString !== null) { + // `undefined` matches no string literal; any other value is the + // string itself and dispatches as a string switch. + const value = emitter.emitExpr(nullableString.value); + const present = B.tmp(); + const dispatch = B.newLabel("sw.string"); + B.line(`${present} = icmp ne ptr ${value.name}, null`); + B.condBr(present, dispatch, fallback); + B.startBlock(dispatch); + const text: LlValue = { name: value.name, type: STRING }; + if (!emitLiteralSwitch(emitter, text, undefined, nullableString.cases, caseLabels, fallback)) { + nullableString.cases.forEach((c, i) => { + if (c.test?.kind !== "strLit") return; + const hit = B.tmp(); + B.line( + `${hit} = call zeroext i1 ${emitter.literalEquals(c.test.value)}(ptr ${value.name})`, + ); + const next = B.newLabel("sw.t"); + B.condBr(hit, caseLabels[i]!, next); + B.startBlock(next); + }); + B.br(fallback); + } + } else if (!emitLiteralSwitch(emitter, disc, s.disc, s.cases, caseLabels, fallback)) { s.cases.forEach((c, i) => { if (c.test === null) return; // Lazy source-order test evaluation (a test after the match never @@ -1983,3 +2009,74 @@ export function emitStringSelfConcatAssign( } return result; } + +/** `switch (x)` on a `string | undefined` value whose case tests are all + * string literals: the frontend's `switch (true)` over `x === "lit"` union + * equalities against one switch local. The local and the cases with plain + * string literal tests. */ +function nullableStringSwitch( + emitter: LlEmitter, + s: IrStmt & { kind: "switch" }, +): { value: IrExpr; cases: (IrStmt & { kind: "switch" })["cases"] } | null { + if (s.disc.kind !== "boolLit" || !s.disc.value) return null; + let value: (IrExpr & { kind: "varRef" }) | null = null; + const cases: (IrStmt & { kind: "switch" })["cases"] = []; + for (const c of s.cases) { + const test = c.test; + if (test === null) { + cases.push(c); + continue; + } + if ( + test.kind !== "unionEq" || + test.negated || + test.sameValue || + test.left.kind !== "varRef" || + test.right.kind !== "unionWrap" || + test.right.value.kind !== "strLit" + ) + return null; + const nullable = emitter.nullableUnions.get(test.unionId); + if ( + nullable === null || + nullable.repr !== "single" || + nullable.nullTag !== -1 || + nullable.arm.kind !== "string" || + test.right.tag !== nullable.refTag + ) + return null; + if (value !== null && value.localId !== test.left.localId) return null; + value = test.left; + cases.push({ ...c, test: test.right.value }); + } + return value === null ? null : { value, cases }; +} + +/** `switch (s.charAt(i))` (or `switch (s[i])`) whose case tests are all + * one-unit string literals, as a switch on `s.charCodeAt(i)` with the units + * as cases: no character string is built. An index out of range answers + * NaN, which matches no case, as "" matches no one-unit literal. */ +function charAtUnitSwitch(s: IrStmt & { kind: "switch" }): (IrStmt & { kind: "switch" }) | null { + const disc = s.disc; + if (disc.kind !== "strIntrinsic" || disc.method !== "charAt" || disc.args.length !== 1) + return null; + if (!s.cases.some((c) => c.test !== null)) return null; + if ( + !s.cases.every( + (c) => c.test === null || (c.test.kind === "strLit" && c.test.value.length === 1), + ) + ) + return null; + return { + ...s, + disc: { ...disc, method: "charCodeAt", type: F64 }, + cases: s.cases.map((c) => + c.test?.kind === "strLit" + ? { + ...c, + test: { kind: "numLit", value: c.test.value.charCodeAt(0), type: F64, loc: c.test.loc }, + } + : c, + ), + }; +} diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 35e759110..9cbd8de74 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -1,7 +1,7 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js"; -import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js"; +import { F64, isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js"; import { mangleRecordClone, mangleRecordNew } from "../mangle.js"; import { arrNewCall, elemAccess } from "./shapes.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -558,6 +558,28 @@ export function regexLiteralTemplate( return `@${re.sym}`; } +/** A string equality between `s.charAt(i)` and a literal of exactly one + * UTF-16 code unit: the charAt call and the unit. */ +function charAtUnitTest( + e: ExprOf<"strEq">, +): { charAt: ExprOf<"strIntrinsic">; code: number } | null { + let text: string; + let other: IrExpr; + if (e.right.kind === "strLit") { + text = e.right.value; + other = e.left; + } else if (e.left.kind === "strLit") { + text = e.left.value; + other = e.right; + } else { + return null; + } + if (text.length !== 1) return null; + if (other.kind !== "strIntrinsic" || other.method !== "charAt" || other.args.length !== 1) + return null; + return { charAt: other, code: text.charCodeAt(0) }; +} + export function emitStringExpr( host: LlvmEmitterContext, e: ExprOf< @@ -600,6 +622,26 @@ export function emitStringExpr( return host.own({ name: t, type: e.type }); } case "strEq": { + // `s.charAt(i) === "c"` (and `s[i]`, which lowers to charAt) for a + // one-unit literal compares the code unit: charCodeAt converts the + // index the same way, and an index out of range answers NaN, which + // equals no unit, as "" equals no one-unit literal. + const unit = charAtUnitTest(e); + if (unit !== null) { + const code = host.emitExpr({ + kind: "strIntrinsic", + method: "charCodeAt", + receiver: unit.charAt.receiver, + args: unit.charAt.args, + type: F64, + loc: unit.charAt.loc, + }); + const t = B.tmp(); + B.line( + `${t} = fcmp ${e.negated ? "une" : "oeq"} double ${code.name}, ${f64Lit(unit.code)}`, + ); + return { name: t, type: e.type }; + } const [l, r] = emitStringInputs(host, [e.left, e.right]); const eq = B.tmp(); // A literal side compares its length and bytes inline. diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 4798b1f09..358b21c4f 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -1097,6 +1097,30 @@ export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): B.line(`${t} = call ptr @scr_str_from_char_code_one(double ${code.name})`); return host.own({ name: t, type: e.type }); } + if ( + e.fn === "string.fromCharCode" && + packed.kind === "arrayLit" && + packed.elems.length <= 16 && + !packed.spreads?.length + ) { + // A short argument list passes its codes in a stack array. + const codes = packed.elems.map((elem) => host.emitExpr(elem)); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca [${Math.max(codes.length, 1)} x double]`); + codes.forEach((code, i) => { + const p = B.tmp(); + B.line( + `${p} = getelementptr inbounds [${Math.max(codes.length, 1)} x double], ptr ${slot}, i64 0, i64 ${i}`, + ); + B.line(`store double ${code.name}, ptr ${p}`); + }); + host.declare(`declare ptr @scr_str_from_char_codes(ptr, ${host.sizeType})`); + const t = B.tmp(); + B.line( + `${t} = call ptr @scr_str_from_char_codes(ptr ${slot}, ${host.sizeType} ${codes.length})`, + ); + return host.own({ name: t, type: e.type }); + } // One packed f64[] (the frontend built it) or one bytes value (the // spread-typed-array form); +1 string. const base = diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5607-string-char-compare.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5607-string-char-compare.ts.json new file mode 100644 index 000000000..eef427a5e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5607-string-char-compare.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5607-string-char-compare.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5608-string-char-switch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5608-string-char-switch.ts.json new file mode 100644 index 000000000..855057006 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5608-string-char-switch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5608-string-char-switch.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5609-string-switch-optional.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5609-string-switch-optional.ts.json new file mode 100644 index 000000000..7f0bf061d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5609-string-switch-optional.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5609-string-switch-optional.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5750-string-from-char-codes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5750-string-from-char-codes.ts.json new file mode 100644 index 000000000..fab2fd504 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5750-string-from-char-codes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5750-string-from-char-codes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5751-string-length-cache.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5751-string-length-cache.ts.json new file mode 100644 index 000000000..0eebe2326 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5751-string-length-cache.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5751-string-length-cache.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index ce7de9654..54928f59f 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10400,7 +10400,9 @@ ScrStr *scr_crypto_x509_valid_to_str(ScrStr *pem) { * keep other surrogates lone, WTF-8 encode. */ static ScrStr *scr_str_from_units(size_t n, uint32_t (*unit)(void *, size_t), void *src) { if (n == 1) return scr_str_from_char_code_one((double)unit(src, 0)); - char *out = malloc(n * 3 + 1); /* worst case: 3 bytes per UTF-16 unit */ + /* Up to 64 units encode on the stack (3 bytes per UTF-16 unit at most). */ + char small[64 * 3 + 1]; + char *out = n <= 64 ? small : malloc(n * 3 + 1); if (!out) { scr_trap("scriptc: out of memory\n"); } @@ -10417,10 +10419,19 @@ static ScrStr *scr_str_from_units(size_t n, uint32_t (*unit)(void *, size_t), vo o += scr_wtf8_put(out + o, cp); } ScrStr *s = scr_str_new(out, o); - free(out); + if (out != small) free(out); return s; } +static uint32_t scr_fcc_units_unit(void *src, size_t i) { + return scr_to_uint32(((const double *)src)[i]) & 0xFFFFu; +} + +/* String.fromCharCode(c0, ..., cn-1) with the codes in a caller's array. */ +ScrStr *scr_str_from_char_codes(const double *codes, size_t n) { + return scr_str_from_units(n, scr_fcc_units_unit, (void *)codes); +} + static uint32_t scr_fcc_arr_unit(void *src, size_t i) { return scr_to_uint32(scr_arr_get_f64((ScrArr *)src, (double)i)) & 0xFFFFu; } diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index f43195116..1b85fb0fe 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -52,6 +52,8 @@ ScrStr *scr_bool_to_scrstr(bool b); /* interned "true"/"false" */ * adjacent surrogate pairs combine, other surrogates stay lone. Borrowed * args; the string result is +1; neither throws. */ ScrStr *scr_str_from_char_code(ScrArr *codes); +/* String.fromCharCode over n codes in a caller-owned array (no ScrArr). */ +ScrStr *scr_str_from_char_codes(const double *codes, size_t n); /* Single numeric argument; avoids argument packing. Defined in scr_string.c * to share its character cache. Same ToUint16 and lone-surrogate behavior. */ ScrStr *scr_str_from_char_code_one(double code); diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 8358658b5..77c020ee0 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -115,6 +115,15 @@ static SCR_TL bool scr_sidx_cleanup_registered; * moves one active entry; only clearing or initializing changes the count. */ static SCR_TL size_t scr_sidx_active; +/* UTF-16 length table (scr_str_utf16_len below). */ +#define SCR_U16LEN_N 1024 +typedef struct { + const ScrStr *s; + size_t units; +} ScrU16Len; +/* Allocated by the first length it records; freed with the index. */ +static SCR_TL ScrU16Len *scr_u16len_tab; + static void scr_sidx_clear(ScrSidx *e, unsigned *live) { if (e->s) { scr_sidx_active--; @@ -137,6 +146,8 @@ static void scr_sidx_reset_all(void) { } scr_sidx_sparse_clock = 0; scr_sidx_cursor_clock = 0; + free(scr_u16len_tab); + scr_u16len_tab = NULL; } static void scr_sidx_register_cleanup(void) { @@ -190,7 +201,27 @@ static __attribute__((noinline)) void scr_sidx_purge_active(const ScrStr *s) { } } +/* UTF-16 lengths of non-ASCII strings longer than 64 bytes, direct-mapped + * by address. The four index entries above evict each other when a + * program reads the lengths of more long texts than that in turn (every + * source file of a program), and each miss walks the whole string. A slot + * is dropped wherever the index drops its facts: when the string is freed + * or reallocated, and when an append changes it in place. */ + +/* The slot for s, or NULL before any length is recorded. */ +static inline ScrU16Len *scr_u16len_slot(const ScrStr *s) { + if (!scr_u16len_tab) return NULL; + uint64_t h = ((uint64_t)(uintptr_t)s >> 4) * UINT64_C(0x9E3779B97F4A7C15); + return &scr_u16len_tab[h >> 54]; +} + +static inline void scr_u16len_forget(const ScrStr *s) { + ScrU16Len *slot = scr_u16len_slot(s); + if (slot && slot->s == s) slot->s = NULL; +} + static void scr_sidx_purge(const ScrStr *s) { + scr_u16len_forget(s); if (scr_sidx_active) scr_sidx_purge_active(s); } @@ -288,6 +319,7 @@ static ScrSidx *scr_sidx_for(const ScrStr *s) { * receiver may move from the cursor tier to the sparse tier on its next * lookup, so invalidate either possible entry. */ static void scr_sidx_concat_append(const ScrStr *s, size_t oldlen) { + scr_u16len_forget(s); if (scr_sidx_active == 0) return; for (int i = 0; i < SCR_SIDX_N; i++) { ScrSidx *entries[] = {&scr_sidx_sparse_tab[i], &scr_sidx_cursor_tab[i]}; @@ -1400,6 +1432,8 @@ static void scr_sidx_materialize_identity_prefix(const ScrStr *s, ScrSidx *e) { static size_t scr_sidx_len(const ScrStr *s, ScrSidx *e) { if (s->ascii) return s->len; if (e->u16len != SCR_U16_UNKNOWN) return e->u16len; + ScrU16Len *slot = scr_u16len_slot(s); + if (slot && slot->s == s) return e->u16len = slot->units; while (e->indexed_cb < s->len) scr_sidx_extend_one(s, e); scr_sidx_finish(s, e); return s->ascii ? s->len : e->u16len; @@ -1899,7 +1933,20 @@ double scr_str_utf16_len(ScrStr *s) { ScrSidx *last = scr_sidx_last; /* a scan's receiver, length known */ if (last && last->s == s && last->u16len != SCR_U16_UNKNOWN) return (double)(int64_t)last->u16len; /* lengths fit int64_t */ - return (double)scr_sidx_len(s, scr_sidx(s)); + ScrU16Len *slot = scr_u16len_slot(s); + if (slot && slot->s == s) return (double)(int64_t)slot->units; + size_t units = scr_sidx_len(s, scr_sidx(s)); + /* The walk may prove the string ASCII, which needs no slot. */ + if (s->ascii) return (double)(int64_t)units; + if (!scr_u16len_tab) { + /* Without memory the lengths are counted on each read, as before. */ + scr_u16len_tab = calloc(SCR_U16LEN_N, sizeof *scr_u16len_tab); + if (!scr_u16len_tab) return (double)(int64_t)units; + scr_sidx_register_cleanup(); + slot = scr_u16len_slot(s); + } + *slot = (ScrU16Len){s, units}; + return (double)(int64_t)units; } double scr_str_char_code_at(ScrStr *s, double i) { diff --git a/tests/corpus/5607-string-char-compare.ts b/tests/corpus/5607-string-char-compare.ts new file mode 100644 index 000000000..f80581c10 --- /dev/null +++ b/tests/corpus/5607-string-char-compare.ts @@ -0,0 +1,37 @@ +// Comparing one character of a string with a one-unit literal: index and +// charAt forms, === and !==, the literal on either side, indexes out of +// range, negative, fractional and NaN, multi-byte and astral text, lone +// surrogates, and literals of other lengths. +function bits(s: string, i: number): string { + return [ + s[i] === "_", s.charAt(i) === "_", "_" === s.charAt(i), s.charAt(i) !== "_", s[i] !== "_", + s[i] === "a", s.charAt(i) === "a", "a" === s.charAt(i), s.charAt(i) !== "a", s[i] !== "a", + s[i] === "é", s.charAt(i) === "é", "é" === s.charAt(i), s.charAt(i) !== "é", s[i] !== "é", + s[i] === "\uD83D", s.charAt(i) === "\uD83D", "\uD83D" === s.charAt(i), s.charAt(i) !== "\uD83D", s[i] !== "\uD83D", + s[i] === "\uDE00", s.charAt(i) === "\uDE00", "\uDE00" === s.charAt(i), s.charAt(i) !== "\uDE00", s[i] !== "\uDE00", + s[i] === "\uD800", s.charAt(i) === "\uD800", "\uD800" === s.charAt(i), s.charAt(i) !== "\uD800", s[i] !== "\uD800", + s[i] === "語", s.charAt(i) === "語", "語" === s.charAt(i), s.charAt(i) !== "語", s[i] !== "語", + s[i] === "", s.charAt(i) === "", "" === s.charAt(i), s.charAt(i) !== "", s[i] !== "", + s[i] === "ab", s.charAt(i) === "ab", "ab" === s.charAt(i), s.charAt(i) !== "ab", s[i] !== "ab", + ] + .map((b) => (b ? "1" : "0")) + .join(""); +} +const samples: string[] = ["a_b", "", "é_ü", "x😀y", "\uD800z", "日本_語", "ab"]; +const indexes = [0, 1, 2, 3, 4, -1, 1.5, NaN, 100]; +for (const s of samples) console.log(JSON.stringify(s), indexes.map((i) => bits(s, i)).join(" ")); + +function countUnderscores(text: string): number { + let n = 0; + for (let i = 0; i < text.length; i++) if (text[i] === "_") n++; + return n; +} +function firstSpace(text: string): number { + for (let i = 0; i < text.length; i++) if (text.charAt(i) === " ") return i; + return -1; +} +console.log(countUnderscores("a_b_c__d"), countUnderscores(""), firstSpace("type Foo = Bar"), firstSpace("none")); +let k = 0; +const s = "ab"; +const at = (): number => k++; +console.log(s.charAt(at()) === "a", s.charAt(at()) === "a", k); diff --git a/tests/corpus/5608-string-char-switch.ts b/tests/corpus/5608-string-char-switch.ts new file mode 100644 index 000000000..8ea54f1f5 --- /dev/null +++ b/tests/corpus/5608-string-char-switch.ts @@ -0,0 +1,95 @@ +// switch on one character of a string with one-unit case literals: the +// index and charAt forms, indexes out of range, negative, fractional and +// NaN, astral text and lone surrogates, duplicate cases, fallthrough, a +// switch without default, few and many cases, and an index expression +// with a side effect. +function classify(ch: string): string { + return ch; +} +function kind(s: string, i: number): string { + switch (s[i]) { + case "(": + case ")": + return "paren"; + case "{": + return "brace"; + case "é": + return "e-acute"; + case "\uD83D": + return "high"; + case "\uDE00": + return "low"; + case "\uD800": + return "lone"; + case "(": + return "duplicate"; + case " ": + return "space"; + default: + return "other"; + } +} +function few(s: string, i: number): number { + let n = 0; + switch (s.charAt(i)) { + case "a": + n += 1; + case "b": + n += 2; + break; + case "c": + n += 4; + } + return n; +} +const samples = ["(x) {é}", "😀", "\uD800", "", "abc", " "]; +const indexes = [0, 1, 2, 3, -1, 0.5, NaN, 100]; +for (const s of samples) { + console.log( + JSON.stringify(s), + indexes.map((i) => kind(s, i)).join(","), + indexes.map((i) => few(s, i)).join(","), + ); +} +let calls = 0; +const text = "abc"; +const next = (): number => calls++; +for (let r = 0; r < 4; r++) { + switch (text.charAt(next())) { + case "a": + console.log("a", calls); + break; + case "c": + console.log("c", calls); + break; + default: + console.log("default", calls); + } +} +function tokens(text: string): string { + const out: string[] = []; + for (let i = 0; i < text.length; i++) { + switch (text[i]) { + case "(": + case ")": + out.push("p"); + break; + case "+": + case "-": + case "*": + case "/": + out.push("o"); + break; + case "\uD83D": + out.push("h"); + break; + case " ": + break; + default: + out.push(text[i]); + } + } + return out.join(""); +} +console.log(tokens("(a + b) * c / d - é"), tokens("x😀y"), tokens("")); +console.log(classify("ok")); diff --git a/tests/corpus/5609-string-switch-optional.ts b/tests/corpus/5609-string-switch-optional.ts new file mode 100644 index 000000000..20e36b739 --- /dev/null +++ b/tests/corpus/5609-string-switch-optional.ts @@ -0,0 +1,57 @@ +// switch on a string | undefined value with string literal cases: array +// reads, optional fields and Map lookups that may be undefined, matching +// and missing values, the empty string, non-ASCII and long literals, +// duplicate cases, fallthrough, default in the middle, and few cases. +interface Token { + text?: string; +} +function kind(t: string | undefined): string { + switch (t) { + case "function": + case "class": + return "decl"; + case "": + return "empty"; + case "naïve": + return "unicode"; + case "a-much-longer-keyword-than-eight-bytes": + return "long"; + case "function": + return "duplicate"; + default: + return t === undefined ? "missing" : "other"; + case "return": + return "flow"; + } +} +function fallthrough(t: string | undefined): number { + let n = 0; + switch (t) { + case "a": + n += 1; + case "b": + n += 2; + break; + default: + n += 8; + case "c": + n += 4; + } + return n; +} +function few(t: string | undefined): string { + switch (t) { + case "x": + return "x"; + } + return "none"; +} +const words = ["function", "class", "", "naïve", "a-much-longer-keyword-than-eight-bytes", "return", "other", "Function"]; +for (let i = 0; i <= words.length; i++) { + const w: string | undefined = words[i]; + console.log(i, kind(w), fallthrough(w), few(w)); +} +const tokens: Token[] = [{ text: "a" }, {}, { text: "c" }, { text: "x" }]; +console.log(tokens.map((t) => `${kind(t.text)}:${fallthrough(t.text)}:${few(t.text)}`).join(" ")); +const table = new Map([["k", "return"], ["e", ""]]); +console.log(["k", "e", "z"].map((key) => kind(table.get(key))).join(" ")); diff --git a/tests/corpus/5750-string-from-char-codes.ts b/tests/corpus/5750-string-from-char-codes.ts new file mode 100644 index 000000000..6229e6d81 --- /dev/null +++ b/tests/corpus/5750-string-from-char-codes.ts @@ -0,0 +1,27 @@ +// String.fromCharCode with several arguments: ASCII and non-ASCII units, +// surrogate pairs that combine, lone and reversed surrogates, ToUint16 of +// large, negative, fractional and NaN codes, zero to seventeen arguments, +// and the spread form. +const show = (s: string): string => + `${JSON.stringify(s)}:${s.length}:${Array.from(s, (c) => c.codePointAt(0)!.toString(16)).join(".")}`; +console.log(show(String.fromCharCode())); +console.log(show(String.fromCharCode(104, 105))); +console.log(show(String.fromCharCode(0xe9, 0x65e5, 0x41))); +console.log(show(String.fromCharCode(0xd83d, 0xde00))); +console.log(show(String.fromCharCode(0xde00, 0xd83d))); +console.log(show(String.fromCharCode(0xd800, 0x41, 0xdc00))); +console.log(show(String.fromCharCode(0x10041, -1, 65.9, NaN, 2 ** 32 + 66))); +console.log( + show(String.fromCharCode(97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112)), +); +console.log( + show(String.fromCharCode(97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113)), +); +const codes = [0x48, 0xd83d, 0xde00, 0x21]; +console.log(show(String.fromCharCode(...codes))); +let out = ""; +for (let i = 0; i < 26; i++) out += String.fromCharCode(97 + i, 65 + i); +console.log(out, out.length); +let calls = 0; +const next = (): number => 65 + calls++; +console.log(String.fromCharCode(next(), next(), next()), calls); diff --git a/tests/corpus/5751-string-length-cache.ts b/tests/corpus/5751-string-length-cache.ts new file mode 100644 index 000000000..e0e5f8c67 --- /dev/null +++ b/tests/corpus/5751-string-length-cache.ts @@ -0,0 +1,36 @@ +// .length of long non-ASCII strings: many texts read in turn, appends in +// place (including a lone high surrogate completed by a low one), strings +// freed and rebuilt at reused addresses, split pieces, and slices and +// searches on texts whose lengths were read before. +let unit = ""; +while (unit.length < 300) unit += "naïve 日本語 "; +const texts: string[] = []; +for (let i = 0; i < 40; i++) texts.push(unit.repeat(1 + (i % 7)) + "é".repeat(i)); +let sum = 0; +for (let r = 0; r < 3; r++) for (const t of texts) sum += t.length; +console.log(sum, texts[39].length, texts[0].length); + +let grow = unit; +const lengths: number[] = []; +for (let i = 0; i < 20; i++) { + grow += i % 2 ? "ü" : "x"; + lengths.push(grow.length); +} +console.log(lengths.join(",")); +let pair = unit + "\uD83D"; +const before = pair.length; +pair += "\uDE00"; +console.log(before, pair.length, pair.codePointAt(pair.length - 2)!.toString(16)); + +const seen: number[] = []; +for (let i = 0; i < 50; i++) { + const temp = unit + "ß".repeat(i % 5) + i; + seen.push(temp.length); +} +console.log(seen.slice(0, 10).join(","), seen.reduce((a, b) => a + b, 0)); + +const parts = (unit + "|").repeat(30).split("|"); +console.log(parts.length, parts[0].length, parts.map((p) => p.length).reduce((a, b) => a + b, 0)); + +const big = texts[39]; +console.log(big.slice(big.length - 5), big.substring(10, 20).length, big.indexOf("日本"), big.lastIndexOf("é"), big.length); From 8c5d65212aade390992b034f11cb24da2495af60 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 23:11:39 -0700 Subject: [PATCH 77/85] Throw Node's ReferenceError for __dirname in a JavaScript ES module The read used to defer a refusal, which kept the corpus program from compiling statically. It now throws Node's catchable ReferenceError ("__dirname is not defined"), and typeof answers "undefined" as in Node. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/identifiers.ts | 18 ++++++++++-------- .../frontend/lowering/expressions/typeof.ts | 17 ++++++++++++++++- tests/corpus/6429-esm-js-dirname.mjs | 6 ++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 5463502eb..1d4755467 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -352,9 +352,9 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // (Node's exact values when the same tree runs in place; WASI uses // the equivalent guest-visible path; locals shadowing the names // resolved above), the same strings as import.meta.dirname/filename. - // In a JavaScript ES module Node never defines them (ReferenceError), - // so they fence there. A .ts/.tsx file keeps them even when Node's - // strip-types rule would load it as ESM: TypeScript projects are + // In a JavaScript ES module Node never defines them, so the read throws + // Node's catchable ReferenceError. A .ts/.tsx file keeps them even when + // Node's strip-types rule would load it as ESM: TypeScript projects are // commonly built as CommonJS bundles (tsc, esbuild, tsx) where these // globals exist, and @types/node declares them for every file. if ( @@ -363,11 +363,13 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: ) { const sf = expr.getSourceFile(); if (isNodeEsmFile(sf, lowerer.program) && !/\.tsx?$/.test(sf.fileName)) { - lowerer.unsupported( - "SC1090", - expr, - `'${expr.text}' in an ES module (Node defines the CJS module globals only in CommonJS modules)`, - ); + return { + kind: "libCall", + fn: "global.undefRead", + args: [{ kind: "strLit", value: expr.text, type: STRING, loc }], + type: STRING, + loc, + }; } // The WASI runner mounts the host cwd at guest `/` and the host temp // directory at `/tmp`. Bake the matching GUEST spelling so resource diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts index bed929362..c56ef4097 100644 --- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -28,7 +28,7 @@ import { typeofAnswer, } from "../lower-exprs.js"; import { tryLowerExpression } from "./try-lower-expression.js"; -import { isJsSourceFile } from "../../program.js"; +import { isJsSourceFile, isNodeEsmFile } from "../../program.js"; import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; import { varRef } from "../../../ir/build.js"; import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors.js"; @@ -59,6 +59,21 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc } if (lowerer.isStdlibGlobal(expr.expression, "fetch")) return { kind: "strLit", value: "function", type: STRING, loc }; + // `__dirname` / `__filename` in a JavaScript ES module, where Node never + // defines them: typeof answers "undefined" instead of throwing. + { + let operand = expr.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if ( + ts.isIdentifier(operand) && + (operand.text === "__dirname" || operand.text === "__filename") && + lowerer.isStdlibGlobal(operand, operand.text) + ) { + const sf = operand.getSourceFile(); + if (isNodeEsmFile(sf, lowerer.program) && !/\.tsx?$/.test(sf.fileName)) + return { kind: "strLit", value: "undefined", type: STRING, loc }; + } + } if (isNavigatorGlobal(lowerer, expr.expression)) return { kind: "strLit", value: "object", type: STRING, loc }; const performance = lowerPerfHooksTypeof(lowerer, expr.expression); diff --git a/tests/corpus/6429-esm-js-dirname.mjs b/tests/corpus/6429-esm-js-dirname.mjs index 450544dd7..2ecc4088a 100644 --- a/tests/corpus/6429-esm-js-dirname.mjs +++ b/tests/corpus/6429-esm-js-dirname.mjs @@ -7,3 +7,9 @@ try { console.log("no __dirname:", error instanceof Error); } console.log(import.meta.filename.endsWith("6429-esm-js-dirname.mjs")); +console.log(typeof __dirname, typeof __filename); +try { + console.log(__filename.length); +} catch (error) { + console.log(error.name, error.message); +} From 40f6dd25c8d98d14a388a6bc4a6da449e531a492 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:35:23 -0500 Subject: [PATCH 78/85] Revert "Give interfaces extending class-backed protocols checked identity" --- .../src/frontend/lowering/class-protocols.ts | 6 -- ...4-protocol-subinterface-assertions.ts.json | 6 -- .../6064-protocol-subinterface-assertions.ts | 80 ------------------- ...4-protocol-subinterface-assertions.ts.json | 1 - 4 files changed, 93 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json delete mode 100644 tests/corpus/6064-protocol-subinterface-assertions.ts delete mode 100644 tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 65d74ab9d..5a27f0815 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -722,12 +722,6 @@ export class ClassProtocols { return true; } } - // An interface extending a class-backed protocol holds values of that - // protocol (`interface TupleType extends Type`), so an assertion from - // the base to it keeps the instance and its methods. - const target = type.isTypeReference() ? type.getTarget() : type; - if (target?.isClassOrInterface()) - for (const base of this.checker.getBaseTypes(target)) if (this.protocol(base)) return true; return false; } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json deleted file mode 100644 index e81d7e9e7..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6064-protocol-subinterface-assertions.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/6064-protocol-subinterface-assertions.ts" - ], - "diags": [] -} diff --git a/tests/corpus/6064-protocol-subinterface-assertions.ts b/tests/corpus/6064-protocol-subinterface-assertions.ts deleted file mode 100644 index 1d1656c5c..000000000 --- a/tests/corpus/6064-protocol-subinterface-assertions.ts +++ /dev/null @@ -1,80 +0,0 @@ -// An interface that extends a class-backed protocol holds values of that -// protocol, so asserting a base-typed value to it (`type as -// TupleTypeReference`, where TupleTypeReference redeclares `getTarget(): -// TupleType`) keeps the class instance: its methods run and its fields read -// as on the base type. -interface Type { - readonly flags: number; - getTarget(): Type | undefined; - isTypeReference(): this is TypeReference; - isTupleType(): this is TupleType; -} -interface ObjectType extends Type {} -interface TypeReference extends ObjectType { - getTarget(): Type; -} -interface InterfaceType extends TypeReference {} -interface TupleType extends InterfaceType { - readonly elementFlags: readonly number[]; - readonly fixedLength: number; -} -interface TupleTypeReference extends TypeReference { - getTarget(): TupleType; -} -class CompilerType { - flags: number; - target: CompilerType | undefined; - elementFlags: number[] = []; - fixedLength = 0; - constructor(flags: number, target?: CompilerType) { - this.flags = flags; - this.target = target; - } - getTarget(): CompilerType | undefined { - return this.target; - } - isTypeReference(): boolean { - return (this.flags & 4) !== 0; - } - isTupleType(): boolean { - return (this.flags & 8) !== 0; - } -} -const tuple = new CompilerType(8); -tuple.elementFlags = [1, 1, 2]; -tuple.fixedLength = 2; -const reference = new CompilerType(4, tuple); -const publicType = (t: CompilerType): Type => t as unknown as Type; -function describe(type: Type): string { - if (type.isTypeReference() && type.getTarget().isTupleType()) { - const ref = type as TupleTypeReference; - const target = ref.getTarget(); - return `tuple ${target.fixedLength} [${target.elementFlags.join(",")}]`; - } - return `flags ${type.flags}`; -} -const seen = new Set([publicType(tuple)]); -console.log(describe(publicType(reference)), describe(publicType(tuple)), seen.has(publicType(tuple))); - -interface Shape { - readonly sides: number; - area(): number; -} -interface Square extends Shape { - readonly side: number; -} -class SquareImpl implements Shape { - readonly sides = 4; - readonly side: number; - constructor(side: number) { - this.side = side; - } - area(): number { - return this.side * this.side; - } -} -const shapes: Shape[] = [new SquareImpl(3), new SquareImpl(5)]; -for (const shape of shapes) { - const square = shape as Square; - console.log(square.sides, square.side, square.area(), square === shape); -} diff --git a/tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json b/tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json deleted file mode 100644 index c05586e43..000000000 --- a/tests/harness/corpus-costs/6064-protocol-subinterface-assertions.ts.json +++ /dev/null @@ -1 +0,0 @@ -{ "plain": 4411, "san": 5159 } From 85d420540d9acef4f422e5a0a7bb5fd381a3580c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:46:11 -0500 Subject: [PATCH 79/85] Keep the stored type for annotated asserted bindings A const annotated with the asserted type (const view: Route & { order: number } = route as Route & { order: number }) took the annotated layout, so the initializer was coerced into it: a width copy that read members the writes had not created yet, and writes through the binding no longer reached the original object. Local and module-level consts now keep the stored type when it hosts the annotation, as unannotated ones do. --- .../frontend/lowering/assertion-members.ts | 5 ++-- .../src/frontend/lowering/lower-stmts.ts | 6 +++-- .../corpus/6462-assertion-attached-members.ts | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/assertion-members.ts b/packages/compiler/src/frontend/lowering/assertion-members.ts index 5a578720e..d61c379d6 100644 --- a/packages/compiler/src/frontend/lowering/assertion-members.ts +++ b/packages/compiler/src/frontend/lowering/assertion-members.ts @@ -145,7 +145,8 @@ export function extendAssertedRecordMembers( * the stored type of `value` once the members are attached, exactly as a * local binding does (lowerVarDecl): the assertion keeps the object, and * writes through the binding must reach it. Globals took their checker - * type during collection, before the members existed. */ + * (or annotated) type during collection, before the members existed; an + * annotation the stored type hosts gives way to it the same way. */ function keepStoredTypeOfAssertedGlobals(lowerer: Lowerer, files: readonly ts.SourceFile[]): void { for (const sf of files) { if (sf.isDeclarationFile) continue; @@ -153,7 +154,7 @@ function keepStoredTypeOfAssertedGlobals(lowerer: Lowerer, files: readonly ts.So if (!ts.isVariableStatement(statement)) continue; if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue; for (const decl of statement.declarationList.declarations.nodes) { - if (!ts.isIdentifier(decl.name) || decl.type || !decl.initializer) continue; + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; let init: ts.Expression = decl.initializer; while (ts.isParenthesizedExpression(init)) init = init.expression; if (!ts.isAsExpression(init) && !ts.isTypeAssertion(init)) continue; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 5d9c91634..8b0009257 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5475,10 +5475,12 @@ export function lowerVarDecl( // `const view = route as Route & { order: number }`: the asserted type // refines the record arms the value is stored in (the assertion keeps // that value, see hosted-narrowing.ts). The binding keeps the stored - // type, so writes through it reach the asserted object. + // type, so writes through it reach the asserted object. An annotation + // the stored type hosts (`const view: Route & { order: number } = ...`) + // keeps it too: coercing into the annotated layout would copy the + // value and read members the writes have not created yet. if ( !isLet && - !decl.type && decl.initializer && isAssertionInitializer(decl.initializer) && (settledType.kind === "union" || settledType.kind === "record") && diff --git a/tests/corpus/6462-assertion-attached-members.ts b/tests/corpus/6462-assertion-attached-members.ts index c2a8857fa..8a2be31b7 100644 --- a/tests/corpus/6462-assertion-attached-members.ts +++ b/tests/corpus/6462-assertion-attached-members.ts @@ -33,3 +33,26 @@ const job: Job = { id: "j1" }; const tracked = job as Job & { startedAt: number }; tracked.startedAt = 42; console.log(tracked.startedAt + 1, JSON.stringify(job), Object.keys(job)); + +// An annotated binding of the asserted type keeps the object as well: +// the members appear on it once written, not before. +function stamp(job: Job): Job & { finishedAt: number } { + const done: Job & { finishedAt: number } = job as Job & { finishedAt: number }; + done.finishedAt = 7; + return done; +} +const second: Job = { id: "j2", retries: 1 }; +console.log(stamp(second).finishedAt, JSON.stringify(second), Object.keys(second)); + +const third: Job = { id: "j3" }; +const queued: Job & { queuedAt: number } = third as Job & { queuedAt: number }; +queued.queuedAt = 9; +console.log(queued.queuedAt, JSON.stringify(third), Object.keys(third)); + +function markAnnotated(route: Route): void { + const marked: Route & { internal: boolean } = route as Route & { internal: boolean }; + marked.internal = false; +} +const annotatedRoute: Route = { handle: "hit" }; +markAnnotated(annotatedRoute); +console.log(JSON.stringify(annotatedRoute)); From 9791aa587e11a52a1b49417bc4440bb3284b2080 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:50:55 -0500 Subject: [PATCH 80/85] Return parked workers' memory while the event loop waits, after a delay that grows The trim helper returned parked workers' memory once no worker had run for 100 ms. A compiler in watch mode starts its workers again for each rebuild and faulted the returned pages back in, and a rebuild that runs on the main thread alone had the discards run alongside it. - The delay starts at 1 s (SCR_SA_DISCARD_DELAY_MS) and doubles each time workers start within ten delays of a discard, up to SCR_SA_DISCARD_DELAY_MAX_MS (10 s by default). - Once the event loop of the thread that is not a worker runs, the helper discards one arena at a time, and only while that loop has been in one wait with nothing ready for a step (scr_sa_loop_waiting, called by the event loop around its sleep). A loop that does not wait gets the discards SCR_SA_DISCARD_DELAY_MAX_MS after the delay. - Programs without a running event loop discard after the delay, as before. --- packages/runtime/src/scr_alloc.c | 102 +++++++++++++++++++++++--- packages/runtime/src/scr_alloc.test.c | 87 +++++++++++++++++++++- packages/runtime/src/scr_async.c | 6 ++ packages/runtime/src/scr_rc.h | 4 + 4 files changed, 187 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 246d27f0a..935736612 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -935,12 +935,19 @@ static void scr_sa_trim(void) { * interrupts every running thread of the process to flush its TLB, and * glibc's trim locks the arenas other threads allocate from, so discards * wait for the last worker of a group, which hands them to one helper; - * - the helper waits SCR_SA_DISCARD_DELAY_MS (100 by default) and stops if + * - the helper waits a delay (scr_sa_discard_delay_ms: 1 s to start, + * doubled each time workers start again soon after a discard) and stops if * a worker started meanwhile: a program that starts its next workers or * exits within that time does not return pages it would fault back in or * never touch again, and the last worker of the next group hands the * discards to a new helper; a thread that finds every arena claimed * discards one waiting arena and claims it; + * - once the event loop of the thread that is not a worker runs, the + * helper then discards one arena at a time, and only while that loop + * waits with nothing ready (scr_sa_loop_waiting): a program that does + * its next work on that thread alone (a compiler rebuilding one file in + * watch mode) would otherwise have it slowed by the discards. A loop that + * does not wait gets them SCR_SA_DISCARD_DELAY_MAX_MS after the delay; * - the helper then returns the pages * it carved (MADV_DONTNEED; the start of the first page, which it may * share with older blocks, is cleared), frees the recorded system blocks, @@ -1214,12 +1221,20 @@ void scr_sa_thread_shares(void) { /* Worker threads that started and have not parked. */ static _Atomic(unsigned) scr_sa_workers_live; +#ifdef SCR_SA_TRIM +static void scr_sa_group_starting(void); +#endif + /* Library archives run on their embedder's threads and keep their memory. */ static void scr_sa_worker_starting(void) { #ifndef SCR_LIB scr_sa_worker = true; +#ifdef SCR_SA_TRIM + if (atomic_fetch_add_explicit(&scr_sa_workers_live, 1, memory_order_relaxed) == 0) scr_sa_group_starting(); +#else atomic_fetch_add_explicit(&scr_sa_workers_live, 1, memory_order_relaxed); #endif +#endif } #define SCR_SA_WORKER_STARTING() scr_sa_worker_starting() #endif @@ -1309,28 +1324,91 @@ static bool scr_sa_drain_some(bool one) { static bool scr_sa_drain(void) { return scr_sa_drain_some(false); } -/* SCR_SA_DISCARD_DELAY_MS: how long the trim helper waits after the last - * worker parks before it returns their memory (default 100). */ -static long scr_sa_discard_delay_ms(void) { - const char *delay = getenv("SCR_SA_DISCARD_DELAY_MS"); - if (!delay) return 100; - long ms = strtol(delay, NULL, 10); +/* How long the trim helper waits after the last worker parks before it + * returns their memory: SCR_SA_DISCARD_DELAY_MS (1000 by default) to start. + * Each time workers start again within ten delays of a discard, the delay + * doubles, up to SCR_SA_DISCARD_DELAY_MAX_MS (10000 by default): a program + * that keeps starting workers (a compiler rebuilding in watch mode, a + * server) would fault the returned pages straight back in. Memory that + * stays idle for the grown delay still goes back. */ +static _Atomic(long) scr_sa_delay_grown_ms; /* 0 until a discard proved too early */ +static _Atomic(long) scr_sa_discarded_ms; /* when the helper last discarded; 0: not since */ + +static long scr_sa_env_ms(const char *name, long fallback) { + const char *value = getenv(name); + if (!value) return fallback; + long ms = strtol(value, NULL, 10); return ms < 0 ? 0 : ms; } +static long scr_sa_discard_delay_ms(void) { + long start = scr_sa_env_ms("SCR_SA_DISCARD_DELAY_MS", 1000); + long max = scr_sa_env_ms("SCR_SA_DISCARD_DELAY_MAX_MS", 10000); + long grown = atomic_load_explicit(&scr_sa_delay_grown_ms, memory_order_relaxed); + long delay = grown > start ? grown : start; + return max >= start && delay > max ? max : delay; +} + +static long scr_sa_now_ms(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (long)now.tv_sec * 1000 + now.tv_nsec / 1000000 + 1; +} + +/* The first worker of a group starts: a discard shortly before means the + * delay was too short for this program. */ +static void scr_sa_group_starting(void) { + long at = atomic_exchange_explicit(&scr_sa_discarded_ms, 0, memory_order_relaxed); + if (!at) return; + long delay = scr_sa_discard_delay_ms(); + if (scr_sa_now_ms() - at >= 10 * (delay > 10 ? delay : 10)) return; + long max = scr_sa_env_ms("SCR_SA_DISCARD_DELAY_MAX_MS", 10000); + long grown = delay > 0 ? 2 * delay : 1; + if (grown > max) grown = max; + if (grown > atomic_load_explicit(&scr_sa_delay_grown_ms, memory_order_relaxed)) + atomic_store_explicit(&scr_sa_delay_grown_ms, grown, memory_order_relaxed); +} + +/* The event loop of the thread that is not a worker: an odd count while it + * waits with nothing ready, an even one while it runs, 0 until it first + * waits. Only that thread writes it. */ +static _Atomic(unsigned long) scr_sa_loop_turns; + +void scr_sa_loop_waiting(bool waiting) { + if (scr_sa_worker) return; + unsigned long turns = atomic_load_explicit(&scr_sa_loop_turns, memory_order_relaxed); + if ((bool)(turns & 1) != waiting) atomic_store_explicit(&scr_sa_loop_turns, turns + 1, memory_order_relaxed); +} + /* Waits out the delay first, and stops if a worker starts meanwhile: the * discards then wait for the last worker of that group. A program that exits * within the delay does not spend its last milliseconds returning pages, and * one that starts its next workers within it does not return pages while - * they start. */ + * they start. Once the event loop runs, each discard then waits until the + * loop has been in one wait for a step, or until the maximum delay has + * passed. */ static void *scr_sa_drain_main(void *arg) { (void)arg; - for (long left = scr_sa_discard_delay_ms(); left > 0; left -= 5) { - struct timespec pause = {0, (left < 5 ? left : 5) * 1000000L}; + long delay = scr_sa_discard_delay_ms(), step = delay > 200 ? 20 : 5; + for (long left = delay; left > 0; left -= step) { + struct timespec pause = {0, (left < step ? left : step) * 1000000L}; nanosleep(&pause, NULL); if (atomic_load_explicit(&scr_sa_workers_live, memory_order_acquire) != 0) return NULL; } - if (atomic_load_explicit(&scr_sa_workers_live, memory_order_acquire) == 0) scr_sa_drain(); + long max = scr_sa_env_ms("SCR_SA_DISCARD_DELAY_MAX_MS", 10000); + unsigned long seen = 0; + for (long waited = 0; atomic_load_explicit(&scr_sa_workers_live, memory_order_acquire) == 0;) { + unsigned long turns = atomic_load_explicit(&scr_sa_loop_turns, memory_order_relaxed); + if (turns == 0 || ((turns & 1) && turns == seen) || waited >= max) { + if (!scr_sa_drain_some(true)) break; + atomic_store_explicit(&scr_sa_discarded_ms, scr_sa_now_ms(), memory_order_relaxed); + continue; + } + seen = turns; + struct timespec pause = {0, step * 1000000L}; + nanosleep(&pause, NULL); + waited += step; + } return NULL; } @@ -1361,6 +1439,7 @@ void scr_sa_wait_trims(void) { #else void scr_sa_wait_trims(void) {} void scr_sa_thread_shares(void) {} +void scr_sa_loop_waiting(bool waiting) { (void)waiting; } #endif static void scr_sa_park_arena(void); @@ -1564,6 +1643,7 @@ void *scr_mem_realloc(void *p, size_t n) { return realloc(p, n); } void scr_sa_thread_park(void) {} void scr_sa_wait_trims(void) {} void scr_sa_thread_shares(void) {} +void scr_sa_loop_waiting(bool waiting) { (void)waiting; } void scr_sa_free_foreign(void *p) { free(p); } #endif #endif diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index aa6b0b60b..6c83afdf9 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -700,11 +700,91 @@ static void check_discard_delay(void) { assert(pthread_join(holder, NULL) == 0); assert(pthread_join(plain, NULL) == 0); scr_sa_wait_trims(); - unsetenv("SCR_SA_DISCARD_DELAY_MS"); + setenv("SCR_SA_DISCARD_DELAY_MS", "100", 1); assert(resident_pages(first, discard_hi) < total / 2); discard_in_old_arena = true; } +/* A discard that workers follow within ten delays was too early: the next + * delay is twice as long, and memory idle for that long still goes back. */ +static void *plain_discard_worker(void *arg) { + (void)arg; + scr_sa_thread_starting(); + allocate_discardable(); + scr_sa_thread_park(); + return NULL; +} + +static void check_discard_delay_grows(void) { + size_t page = (size_t)sysconf(_SC_PAGESIZE); + setenv("SCR_SA_DISCARD_DELAY_MS", "100", 1); + setenv("SCR_SA_DISCARD_DELAY_MAX_MS", "10000", 1); + discard_blocks = malloc(DISCARD_BLOCKS * sizeof *discard_blocks); + assert(discard_blocks); + /* A plain thread holds the arena whose slice the earlier checks filled. */ + atomic_store(&discard_step, 0); + pthread_t plain; + assert(pthread_create(&plain, NULL, holding_thread, NULL) == 0); + wait_step(1); + run(plain_discard_worker); /* returned after the delay */ + pthread_t t; + assert(pthread_create(&t, NULL, plain_discard_worker, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + uintptr_t first = (discard_lo + page - 1) & ~(uintptr_t)(page - 1); + size_t total = (discard_hi - first) / page; + struct timespec pause = {0, 140000000L}; + nanosleep(&pause, NULL); + assert(resident_pages(first, discard_hi) > total / 2); /* the delay is 200 ms or more now */ + scr_sa_wait_trims(); + assert(resident_pages(first, discard_hi) < total / 2); + setenv("SCR_SA_DISCARD_DELAY_MS", "100", 1); + setenv("SCR_SA_DISCARD_DELAY_MAX_MS", "0", 1); + atomic_store(&discard_step, 3); + assert(pthread_join(plain, NULL) == 0); + free(discard_blocks); +} + +/* Once the event loop runs, discards wait for it to wait with nothing + * ready; a loop that does not wait gets them SCR_SA_DISCARD_DELAY_MAX_MS + * after the delay. The test's thread plays the loop's. */ +static void check_discard_waits_for_loop(void) { + size_t page = (size_t)sysconf(_SC_PAGESIZE); + setenv("SCR_SA_DISCARD_DELAY_MS", "100", 1); + setenv("SCR_SA_DISCARD_DELAY_MAX_MS", "10000", 1); + discard_blocks = malloc(DISCARD_BLOCKS * sizeof *discard_blocks); + assert(discard_blocks); + atomic_store(&discard_step, 0); + pthread_t plain; + assert(pthread_create(&plain, NULL, holding_thread, NULL) == 0); + wait_step(1); + scr_sa_loop_waiting(true); + scr_sa_loop_waiting(false); /* the loop runs */ + pthread_t t; + assert(pthread_create(&t, NULL, plain_discard_worker, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + uintptr_t first = (discard_lo + page - 1) & ~(uintptr_t)(page - 1); + size_t total = (discard_hi - first) / page; + struct timespec pause = {0, 900000000L}; + nanosleep(&pause, NULL); + assert(resident_pages(first, discard_hi) > total / 2); /* past the delay */ + scr_sa_loop_waiting(true); + scr_sa_wait_trims(); + assert(resident_pages(first, discard_hi) < total / 2); + /* A loop that keeps running. */ + scr_sa_loop_waiting(false); + setenv("SCR_SA_DISCARD_DELAY_MAX_MS", "300", 1); + assert(pthread_create(&t, NULL, plain_discard_worker, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + first = (discard_lo + page - 1) & ~(uintptr_t)(page - 1); + total = (discard_hi - first) / page; + scr_sa_wait_trims(); + assert(resident_pages(first, discard_hi) < total / 2); + setenv("SCR_SA_DISCARD_DELAY_MAX_MS", "0", 1); + atomic_store(&discard_step, 3); + assert(pthread_join(plain, NULL) == 0); + free(discard_blocks); +} + static void check_discard(void) { discard_blocks = malloc(DISCARD_BLOCKS * sizeof *discard_blocks); assert(discard_blocks); @@ -819,10 +899,15 @@ int main(void) { check_heap_huge_pages(); #endif #if defined(SCR_WORKERS) && SCR_SMALL_ALLOC + /* A short discard delay, which stays at its start until check_discard_delay_grows. */ + setenv("SCR_SA_DISCARD_DELAY_MS", "100", 1); + setenv("SCR_SA_DISCARD_DELAY_MAX_MS", "0", 1); check_threads(); #ifdef __linux__ check_trim(); check_discard(); + check_discard_delay_grows(); + check_discard_waits_for_loop(); #endif printf("allocator checks passed: size-class workers\n"); #else diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 155f91be4..8185422af 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -4410,6 +4410,9 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { #endif #if !defined(_WIN32) && !defined(__wasi__) if (due > now) scr_fiber_stacks_clear(); +#endif +#ifdef SCR_WORKERS + if (due > now) scr_sa_loop_waiting(true); #endif bool evw = scr_events_watching_fn != NULL && scr_events_watching_fn(); #ifdef SCR_WORKERS @@ -4564,6 +4567,9 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { now = due; } } +#ifdef SCR_WORKERS + scr_sa_loop_waiting(false); +#endif while (scr_ntimers > 0 && scr_timers[0].deadline_ms <= now) { ScrTimer t = scr_timer_pop(); /* Timer callbacks are plain sync closures, run on the main stack. diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index eb28fb2e7..8611741da 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -352,6 +352,10 @@ void scr_sa_thread_starting(void); * pointer, or a process-wide cache keeps an immortal object. Its memory * then stays when it exits (scr_alloc.c). */ void scr_sa_thread_shares(void); +/* The event loop of the calling thread is about to wait with nothing ready + * (true), or is done waiting (false); worker threads are ignored. The + * memory of parked workers goes back while the loop waits (scr_alloc.c). */ +void scr_sa_loop_waiting(bool waiting); #define SCR_SA_FREE_FOREIGN(p) scr_sa_free_foreign(p) #else #define SCR_SA_FREE_FOREIGN(p) free(p) From 5a38f414dd228f131f207dee4c63cff8f5b187d9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:54:30 -0500 Subject: [PATCH 81/85] Resolve parameter-list references without the function body's vars A function's parameter initializers run in their own scope, which sees the parameters and a function expression's own name but not the body's `var`s. Chunk splitting resolved them against the body's names too, so a helper that a default value reads was not copied into the chunk when the body declared a `var` of the same name, and the chunk read an undefined binding. A computed method name now resolves outside the method. A named function expression whose body declares a `var` of its own name still exposes the shared function to its parameter initializers, so those reads must be calls for the function to travel with a chunk. --- .../npm-static-bundled-chunks.test.ts | 24 ++++++ .../src/frontend/npm-static-bundled-chunks.ts | 80 ++++++++++++++++--- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts index f9463ea1e..6bc830d74 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -172,6 +172,25 @@ console.log(require_g().join(",")); expect(chunk).toContain("var fact = function self(n)"); }); +test("a helper a parameter default reads travels with a chunk whose body declares its name", () => { + const answer = expectSameBehavior(`${keyedHelper}var ten = function () { + return 10; +}; +var add = function (n, base = ten()) { + var ten = 1; + return n + base + ten; +}; +var require_g = __commonJS({ + "g.js"(exports2, module2) { + module2.exports = add(1); + } +}); +console.log(require_g()); +`); + const chunk = answer.chunks[0]!.source; + expect(chunk).toContain("var ten = function ()"); +}); + test.each([ ["exports it", `function tally() {}`, `module2.exports = { tally };`], ["reads its properties", `function tally() {}`, `exports2.calls = tally.calls;`], @@ -187,6 +206,11 @@ test.each([ `var tally = function self(box = self) { box.n = (box.n || 0) + 1; return box.n; };`, `exports2.count = tally();`, ], + [ + "calls a function whose parameter default reads its own name past a body var of that name", + `var tally = function self(box = self) { var self = 0; box.n = (box.n || 0) + 1; return box.n + self; };`, + `exports2.count = tally();`, + ], ])("a chunk that %s keeps the package refused", (_, declaration, statement) => { const result = split(`${keyedHelper}${declaration} var require_f = __commonJS({ diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index 276079494..33ff865ec 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -146,6 +146,26 @@ function varScopedNames(body: ts.Node): Set { return out; } +/** The names a function binds for references in its parameter list + * (default values, computed keys of destructuring patterns): its + * parameters and a function expression's own name. Parameter initializers + * run in their own scope, so the body's `var`s are not visible there. */ +function parameterScopeNames(scope: ts.SignatureDeclaration): Set { + const out = new Set(); + for (const param of scope.parameters.nodes) addBindingNames(param.name, out); + if (ts.isFunctionExpression(scope) && scope.name !== undefined) out.add(scope.name.text); + return out; +} + +const NO_NAMES: ReadonlySet = new Set(); + +/** True when `node` lies inside `ancestor` (or is it). */ +function within(node: ts.Node, ancestor: ts.Node): boolean { + for (let n: ts.Node | undefined = node; n !== undefined; n = n.parent) + if (n === ancestor) return true; + return false; +} + /** The names one scope node binds for the references inside it. */ function scopeNames(scope: ts.Node): Set { const out = new Set(); @@ -189,6 +209,7 @@ function scopeNames(scope: ts.Node): Set { * names for the bundle's split. */ class BundleScopes { private readonly names = new Map>(); + private readonly parameterNames = new Map>(); private of(scope: ts.Node): Set { let names = this.names.get(scope); @@ -199,11 +220,26 @@ class BundleScopes { return names; } + /** The names `scope` binds for a reference reached through its child + * `from`: a function's parameter list sees its parameters and own name + * but not its body's `var`s, and a computed method name sees none of + * them. */ + private visible(scope: ts.Node, from: ts.Node): ReadonlySet { + if (!isFunctionScope(scope) || from === scope.body) return this.of(scope); + if (from === scope.name) return NO_NAMES; + let names = this.parameterNames.get(scope); + if (names === undefined) { + names = parameterScopeNames(scope); + this.parameterNames.set(scope, names); + } + return names; + } + /** True when a scope between the reference and `root` (inclusive) * binds its name. */ boundWithin(id: ts.Identifier, root: ts.Node): boolean { - for (let n = id.parent; n !== undefined; n = n.parent) { - if (this.of(n).has(id.text)) return true; + for (let from: ts.Node = id, n = id.parent; n !== undefined; from = n, n = n.parent) { + if (this.visible(n, from).has(id.text)) return true; if (n === root) return false; } return false; @@ -212,16 +248,24 @@ class BundleScopes { /** True when a scope inside `root` (exclusive) binds the reference's * name. */ boundInside(id: ts.Identifier, root: ts.Node): boolean { - for (let n = id.parent; n !== undefined && n !== root; n = n.parent) { - if (this.of(n).has(id.text)) return true; + for ( + let from: ts.Node = id, n = id.parent; + n !== undefined && n !== root; + from = n, n = n.parent + ) { + if (this.visible(n, from).has(id.text)) return true; } return false; } /** True when the reference resolves to a top-level binding of `sf`. */ topLevel(id: ts.Identifier, sf: ts.SourceFile): boolean { - for (let n = id.parent; n !== undefined && n !== sf; n = n.parent) { - if (this.of(n).has(id.text)) return false; + for ( + let from: ts.Node = id, n = id.parent; + n !== undefined && n !== sf; + from = n, n = n.parent + ) { + if (this.visible(n, from).has(id.text)) return false; } return true; } @@ -370,12 +414,24 @@ function selfCalledOnly( const fn = unparen(node.initializer); if (!ts.isFunctionExpression(fn) || fn.name === undefined || fn.body === undefined) return true; const self = fn.name.text; - // A parameter or `var` of the same name hides the function's own name. - const own = varScopedNames(fn.body); - for (const param of fn.parameters.nodes) addBindingNames(param.name, own); - if (own.has(self)) return true; - // Its parameter initializers see the name too. - return references(fn).every((r) => r.text !== self || scopes.boundInside(r, fn) || isCallee(r)); + // A parameter of the same name hides the function's own name everywhere + // in it; a `var` of the same name hides it only in the body, since the + // parameter initializers do not see the body's `var`s. + if (fn.parameters.nodes.some((p) => bindsName(p.name, self))) return true; + const bodyVar = varScopedNames(fn.body).has(self); + const body = fn.body; + return references(fn).every( + (r) => + r.text !== self || scopes.boundInside(r, fn) || (bodyVar && within(r, body)) || isCallee(r), + ); +} + +/** True when a binding name (an identifier or a destructuring pattern) + * declares `name`. */ +function bindsName(binding: ts.BindingName, name: string): boolean { + const names = new Set(); + addBindingNames(binding, names); + return names.has(name); } /** True when a reference is the function a call invokes: `f(...)`, or the From 0c3a7ae73e5c3ff1ffd1bc9420eccec53881a484 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:00:21 -0500 Subject: [PATCH 82/85] Keep error stacks through computed keys, clones and throwing getters A read through a computed key now counts as a stack read when the program contains the string "stack", so `e[key]` captures frames like `e.stack` does. A key the program assembles from other strings or from data it reads keeps the static answer. structuredClone of an error held as `any` copies a stack that was already formatted as a non-enumerable data property, as it did before the stack became an accessor. When a name or message getter throws while the stack is formatted, the stack accessor propagates the exception instead of reading the missing string. --- .../src/backend/stack-observations.test.ts | 17 +++++++++ .../src/backend/stack-observations.ts | 36 ++++++++++++++----- packages/runtime/src/scr_json.c | 14 +++++++- tests/corpus/5916-dyn-error-stack-property.ts | 21 +++++++++++ .../6400-dyn-error-stack-property-js.js | 22 ++++++++++++ 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/stack-observations.test.ts b/packages/compiler/src/backend/stack-observations.test.ts index a09f7f58d..0e1178ce9 100644 --- a/packages/compiler/src/backend/stack-observations.test.ts +++ b/packages/compiler/src/backend/stack-observations.test.ts @@ -67,6 +67,23 @@ test("a stack read on a checked-dynamic value is observed", () => { expect(observesStacks(mod([fn("%main", [read("message")])]))).toBe(false); }); +test("a computed key reads the stack when the program spells it", () => { + const value: IrExpr = { loc, kind: "varRef", localId: "d", type: DYN }; + const key: IrExpr = { loc, kind: "varRef", localId: "k", type: STRING }; + const computed: IrStmt = { + loc, + kind: "exprStmt", + expr: { loc, kind: "dynKeyGet", value, key, type: DYN }, + }; + const spelled: IrStmt = { + loc, + kind: "exprStmt", + expr: { loc, kind: "strLit", value: "stack", type: STRING }, + }; + expect(observesStacks(mod([fn("%main", [computed, spelled])]))).toBe(true); + expect(observesStacks(mod([fn("%main", [computed])]))).toBe(false); +}); + test("programs without stack reads are not", () => { expect(observesStacks(mod([fn("%main", [call("helper")]), fn("helper", [ret])]))).toBe(false); }); diff --git a/packages/compiler/src/backend/stack-observations.ts b/packages/compiler/src/backend/stack-observations.ts index 4894e927a..cc6d109e4 100644 --- a/packages/compiler/src/backend/stack-observations.ts +++ b/packages/compiler/src/backend/stack-observations.ts @@ -13,15 +13,34 @@ import { writesPublished } from "./publication.js"; /** Lib calls that read a captured stack trace. */ const STACK_READS: ReadonlySet = new Set(["error.stack"]); -/** A typed `error.stack` read, or a `stack` read on a checked-dynamic - * value, which reaches an error's own `stack` accessor. */ -function readsStack(expr: IrExpr): boolean { +/** A typed `error.stack` read, or a read on a checked-dynamic value that + * can reach an error's own `stack` accessor: the constant key `stack`, or + * a computed key when `computedKeys` (the program spells "stack", so a + * computed key can name it). A key the program assembles from other + * strings or from data it reads keeps the static answer. */ +function readsStack(expr: IrExpr, computedKeys: boolean): boolean { if (expr.kind === "libCall") return STACK_READS.has(expr.fn); - return expr.kind === "dynKeyGet" && expr.key.kind === "strLit" && expr.key.value === "stack"; + if (expr.kind !== "dynKeyGet") return false; + if (expr.key.kind === "strLit") return expr.key.value === "stack"; + return computedKeys; } -function containsStackRead(fn: IrFunction): boolean { - return !everyStmtList(fn.body, { stmt: () => true, expr: (expr) => !readsStack(expr) }); +/** Whether the module contains the string literal "stack". */ +function spellsStack(mod: IrModule): boolean { + return mod.functions.some( + (fn) => + !everyStmtList(fn.body, { + stmt: () => true, + expr: (expr) => !(expr.kind === "strLit" && expr.value === "stack"), + }), + ); +} + +function containsStackRead(fn: IrFunction, computedKeys: boolean): boolean { + return !everyStmtList(fn.body, { + stmt: () => true, + expr: (expr) => !readsStack(expr, computedKeys), + }); } /** Visit every expression of `body` that execution can reach: statements @@ -46,7 +65,8 @@ function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void { * and those reads must not cost every call of the program a frame. * Programs without any stack read answer false without the walk. */ export function observesStacks(mod: IrModule): boolean { - if (!mod.functions.some(containsStackRead)) return false; + const computedKeys = spellsStack(mod); + if (!mod.functions.some((fn) => containsStackRead(fn, computedKeys))) return false; const byName = new Map(mod.functions.map((fn) => [fn.name, fn])); const classNames = new Set((mod.classes ?? []).map((cls) => cls.name)); const pending: string[] = [...moduleFunctionReferences(mod)]; @@ -70,7 +90,7 @@ export function observesStacks(mod: IrModule): boolean { const fn = byName.get(name); if (fn === undefined) continue; visitLiveExprs(fn.body, (expr) => { - if (readsStack(expr)) observed = true; + if (readsStack(expr, computedKeys)) observed = true; if (expr.kind === "call" && !reached.has(expr.callee)) pending.push(expr.callee); const referenced = exprFunctionReference(expr); if (referenced !== undefined && !reached.has(referenced)) pending.push(referenced); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index e41c2c37d..04fbe038b 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -6754,6 +6754,8 @@ static ScrDyn *scr_error_stack_get_call(ScrClosure *closure, ScrDyn *const *args if (!error) return scr_dyn_retain(scr_dyn_undefined()); ScrStr *stack = scr_error_stack(error); scr_error_release(error); + /* A name or message getter that throws leaves its exception pending. */ + if (!stack) return NULL; ScrDyn *out = scr_dyn_new_str(stack); scr_str_release(stack); return out; @@ -10954,7 +10956,17 @@ static ScrDyn *scr_sc_clone(const ScrDyn *v, const ScrScParent *up, ScrDynRegion ScrDyn *out = scr_dyn_new_obj(); for (size_t i = 0; i < v->v.obj.len; i++) { const ScrDynEntry *e = &v->v.obj.entries[i]; - if (scr_dyn_entry_is_error_stack(e)) continue; + if (scr_dyn_entry_is_error_stack(e)) { + /* A stack that was already formatted copies as a non-enumerable + * data property; an unformatted one has nothing to copy. */ + ScrError *error = scr_errdyn_err_of(v); + if (error && error->stack) { + scr_dyn_obj_set(out, "stack", 5, scr_dyn_new_str(error->stack)); + out->v.obj.entries[out->v.obj.len - 1].enumerable = false; + } + scr_error_release(error); + continue; + } ScrDyn *c = scr_sc_clone(e->value, &self, region); if (c == NULL) { scr_dyn_release(out); diff --git a/tests/corpus/5916-dyn-error-stack-property.ts b/tests/corpus/5916-dyn-error-stack-property.ts index 6198e839c..2e1b42ef3 100644 --- a/tests/corpus/5916-dyn-error-stack-property.ts +++ b/tests/corpus/5916-dyn-error-stack-property.ts @@ -124,3 +124,24 @@ function makeSubclass(): any { return dyn(new PlainSubclass("deep")); } console.log("subclass frames", (makeSubclass().stack as string).includes("at makeSubclass")); + +const formatted = dyn(new Error("formatted")); +console.log("formatted before clone", firstLine(formatted.stack)); +console.log("clone keeps a formatted stack", firstLine(dyn(structuredClone(formatted)).stack)); + +class GrumpyError extends Error { + override get name(): string { + throw new Error("no name today"); + } +} +try { + console.log("throwing name getter", firstLine(dyn(new GrumpyError("grumpy")).stack)); +} catch (error) { + console.log("throwing name getter threw", (error as Error).message); +} + +function readKey(target: any, key: string): unknown { + return target[key]; +} +const computed = readKey(inner(), ["sta", "ck"].join("")) as string; +console.log("computed key", firstLine(computed), computed.includes("at inner")); diff --git a/tests/corpus/6400-dyn-error-stack-property-js.js b/tests/corpus/6400-dyn-error-stack-property-js.js index e46db41eb..f0f666103 100644 --- a/tests/corpus/6400-dyn-error-stack-property-js.js +++ b/tests/corpus/6400-dyn-error-stack-property-js.js @@ -124,3 +124,25 @@ function makeSubclass() { return dyn(new PlainSubclass("deep")); } console.log("subclass frames", makeSubclass().stack.includes("at makeSubclass")); + +const formatted = dyn(new Error("formatted")); +console.log("formatted before clone", firstLine(formatted.stack)); +console.log("clone keeps a formatted stack", firstLine(dyn(structuredClone(formatted)).stack)); + +class GrumpyError extends Error { + /** @returns {string} */ + get name() { + throw new Error("no name today"); + } +} +try { + console.log("throwing name getter", firstLine(dyn(new GrumpyError("grumpy")).stack)); +} catch (error) { + console.log("throwing name getter threw", error.message); +} + +function readKey(target, key) { + return target[key]; +} +const computed = readKey(inner(), ["sta", "ck"].join("")); +console.log("computed key", firstLine(computed), computed.includes("at inner")); From bdf1bb973482da3b00b129674aa12ca1a8c336cf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:06:21 -0500 Subject: [PATCH 83/85] Pass rest-parameter functions into nullable and optional fixed-parameter slots Structural conversion lifts a function into a union's function arm only when cleanFuncAdaptable accepts the pair, and it refused every rest signature. A typed-rest function assigned to `((m: string) => void) | null`, an optional parameter of that type, or a `| undefined` binding therefore failed with SC2003. cleanFuncAdaptable now accepts the pairs the rest packing adapter serves, so these slots use the same adapter as a plain fixed-parameter slot. 6445 covers the nullable, optional and undefined slots, and 6445 and 6446 get their corpus costs. --- .../lowering/coercions/function-adapters.ts | 13 ++++++++---- tests/corpus/6445-rest-function-fixed-slot.ts | 21 +++++++++++++++++++ .../6445-rest-function-fixed-slot.ts.json | 1 + ...6-rest-function-nested-declaration.ts.json | 1 + 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 tests/harness/corpus-costs/6445-rest-function-fixed-slot.ts.json create mode 100644 tests/harness/corpus-costs/6446-rest-function-nested-declaration.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 36908e6f9..76f602cf7 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -111,12 +111,15 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool } /** Whether all parameters and the result adapt without a trap-only path. - * Rest signatures are excluded; extra destination parameters are ignored. */ + * Rest signatures are excluded, except a typed-rest source in a slot with + * fixed parameters (the rest packing adapter); extra destination + * parameters are ignored. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, dst: IrType & { kind: "func" }, ): boolean { + if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; if (src.params.length > dst.params.length) return false; for (let i = 0; i < src.params.length; i++) { @@ -127,9 +130,6 @@ export function cleanFuncAdaptable( return src.ret.kind === "void" && dst.ret.kind === "dyn"; } -/** Adapt a function value to a destination signature, forwarding and - * converting its arguments and result. Unsupported parameter/result pairs - * produce checked traps when called; rest signatures remain refused. */ /** Whether a typed-rest function (`(...m: T[]) => R`) can serve a slot * with fixed parameters (`(m: T) => void`): the slot's parameters convert * to the function's fixed parameters, then to the rest element type. */ @@ -200,6 +200,11 @@ function restPackingAdapter( return name; } +/** Adapt a function value to a destination signature, forwarding and + * converting its arguments and result. Unsupported parameter/result pairs + * produce checked traps when called. Rest signatures are refused, except a + * typed-rest source in a slot with fixed parameters, which the rest + * packing adapter serves. */ export function funcCoerceAdapter( lowerer: Lowerer, fromT: IrType & { kind: "func" }, diff --git a/tests/corpus/6445-rest-function-fixed-slot.ts b/tests/corpus/6445-rest-function-fixed-slot.ts index 6c7dbeada..e78e83836 100644 --- a/tests/corpus/6445-rest-function-fixed-slot.ts +++ b/tests/corpus/6445-rest-function-fixed-slot.ts @@ -26,3 +26,24 @@ none(); const count: (tag: string, a: number, b: number) => number = tagged; console.log("count", count("t", 1, 2)); ["a", "b"].forEach((m) => new Line(log).emit(m)); + +// Nullable and optional slots: the value takes the slot's function arm. +class OptionalLine { + private readonly sink: ((m: string) => void) | null; + constructor(sink: ((m: string) => void) | null) { + this.sink = sink; + } + emit(m: string): void { + if (this.sink) this.sink(m); + else console.log("no sink:", m); + } +} +new OptionalLine(log).emit("nullable"); +new OptionalLine(null).emit("dropped"); +function relay(m: string, sink?: (m: string) => void): void { + sink?.(m); +} +relay("optional", log); +relay("absent"); +const maybe: ((a: string, b: string) => void) | undefined = log; +maybe?.("p", "q"); diff --git a/tests/harness/corpus-costs/6445-rest-function-fixed-slot.ts.json b/tests/harness/corpus-costs/6445-rest-function-fixed-slot.ts.json new file mode 100644 index 000000000..858c662a8 --- /dev/null +++ b/tests/harness/corpus-costs/6445-rest-function-fixed-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 2526, "san": 3450 } diff --git a/tests/harness/corpus-costs/6446-rest-function-nested-declaration.ts.json b/tests/harness/corpus-costs/6446-rest-function-nested-declaration.ts.json new file mode 100644 index 000000000..39f698b22 --- /dev/null +++ b/tests/harness/corpus-costs/6446-rest-function-nested-declaration.ts.json @@ -0,0 +1 @@ +{ "plain": 2222, "san": 3035 } From b6ef49d39d95b8ba08354a01c71f749b92416910 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:09:48 -0500 Subject: [PATCH 84/85] Promisify callback-last functions that return a value The promisified executor typed its call of the function as void, so a function with a non-void return type failed to compile with SC9001. The call now has the function's return type, and its value is discarded, as in Node. --- .../lowering/builtins/promisify-callback.ts | 3 ++- tests/corpus/6428-promisify-callback-functions.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts index 693079d3f..cdb8a1fbb 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts @@ -297,7 +297,8 @@ function promisifyMaker(lowerer: Lowerer, shape: CallbackShape, loc: SrcLoc): st callbackParamT, ), ], - type: VOID, + // The function's own return type; the value is discarded. + type: fnT.ret, loc, }, loc, diff --git a/tests/corpus/6428-promisify-callback-functions.ts b/tests/corpus/6428-promisify-callback-functions.ts index 45cf17052..b899dd292 100644 --- a/tests/corpus/6428-promisify-callback-functions.ts +++ b/tests/corpus/6428-promisify-callback-functions.ts @@ -1,7 +1,8 @@ // util.promisify over typed callback-only functions (bound methods, one with -// an optional callback, a function declaration, an inline arrow): the error -// argument rejects, the value fulfills, a synchronous throw rejects, and -// settlement is async. +// an optional callback, a function declaration, an inline arrow, a function +// that returns a value): the error argument rejects, the value fulfills, the +// return value is ignored, a synchronous throw rejects, and settlement is +// async. import { promisify } from "node:util"; class Connection { @@ -32,6 +33,12 @@ function boom(callback: (error: Error | null) => void): void { if (callback !== undefined) throw new TypeError("synchronous failure"); } +let pending = 0; +function size(callback: (error: Error | null, size: number) => void): number { + setImmediate(() => callback(null, 4)); + return ++pending; +} + async function main(): Promise { const c = new Connection("db"); const stats = promisify(c.stats.bind(c)); @@ -68,5 +75,6 @@ async function main(): Promise { console.log(order.join(", ")); await promisify(new Connection("inline").close.bind(new Connection("inline")))(); console.log("inline done"); + console.log("size", await promisify(size)(), pending); } void main(); From b74224f31c4f165a951ecaa12e2c01ca7a654bfd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 23:56:38 -0700 Subject: [PATCH 85/85] Throw checked-dynamic primitives as primitives so catch typeof tests see them A value thrown from `unknown` storage (`throw values[i]`) that holds a number, string or boolean now throws as that primitive, like a statically typed `throw 5`. Before, it threw as a checked-dynamic reference, so `typeof e === "number"` (and "string"/"boolean") on the catch binding answered false and the narrowed reads were unreachable. A primitive has no identity to keep, and reading the binding as `unknown` converts it back. Co-Authored-By: Claude Opus 5.5 --- .../6480-catch-typeof-untyped-throws.ts.json | 6 +++ packages/runtime/src/scr_json.c | 22 ++++++++++ .../6480-catch-typeof-untyped-throws.ts | 40 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6480-catch-typeof-untyped-throws.ts.json create mode 100644 tests/corpus/6480-catch-typeof-untyped-throws.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6480-catch-typeof-untyped-throws.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6480-catch-typeof-untyped-throws.ts.json new file mode 100644 index 000000000..38b99f9fc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6480-catch-typeof-untyped-throws.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6480-catch-typeof-untyped-throws.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 27168dad1..33793c26d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -6359,6 +6359,28 @@ void scr_dyn_throw(ScrDyn *d) { scr_dyn_release(d); return; } + /* A checked-dynamic primitive throws as the primitive itself, as a + * statically typed `throw 5` does: catch bindings test and extract the + * same snapshot kinds (`typeof e === "number"`), and a primitive has no + * identity to keep. */ + if (d->kind == SCR_DYN_NUM) { + double value = d->v.num; + scr_dyn_release(d); + scr_throw_f64(value); + return; + } + if (d->kind == SCR_DYN_BOOL) { + bool value = d->v.b; + scr_dyn_release(d); + scr_throw_bool(value); + return; + } + if (d->kind == SCR_DYN_STR) { + ScrStr *value = scr_str_retain(d->v.str); + scr_dyn_release(d); + scr_throw_str(value); + return; + } scr_throw_ref_classified(d, scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v, scr_dyn_is_object(d)); } diff --git a/tests/corpus/6480-catch-typeof-untyped-throws.ts b/tests/corpus/6480-catch-typeof-untyped-throws.ts new file mode 100644 index 000000000..98122f851 --- /dev/null +++ b/tests/corpus/6480-catch-typeof-untyped-throws.ts @@ -0,0 +1,40 @@ +// Values thrown from `unknown` storage read back through typeof tests on +// the catch binding, like the same values thrown with static types. +const thrown: unknown[] = [5, 0, -1.5, "text", "", true, false, null, undefined, { kind: "box" }]; +function describe(value: unknown): string { + try { + throw value; + } catch (e) { + const same = e === value; + if (typeof e === "number") return `${same} number ${e + 1}`; + if (typeof e === "string") return `${same} string ${e.length}`; + if (typeof e === "boolean") return `${same} boolean ${!e}`; + if (typeof e === "object") return `${same} object ${e === null ? "null" : "value"}`; + return `${same} ${typeof e}`; + } +} +for (const value of thrown) console.log(describe(value)); +function rethrow(value: unknown): void { + try { + throw value; + } catch (e) { + if (typeof e !== "string") throw e; + console.log("kept string", e); + } +} +for (const value of ["s", 7, false]) { + try { + rethrow(value); + } catch (e) { + console.log("rethrown", typeof e, String(e)); + } +} +let count = 0; +for (const value of thrown) { + try { + throw value; + } catch (e) { + if (typeof e === "number" && e > 0) count += e; + } +} +console.log(count);