From de782764c2039b5652dda4626383047316c1024a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 14 Oct 2026 16:55:54 -0700 Subject: [PATCH 001/127] 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 002/127] 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 003/127] 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 004/127] 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 005/127] 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 11c97074e2f5436acdcfe175372b2677fda4abe5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:30 -0700 Subject: [PATCH 006/127] Answer the object a constructor returns from new A class constructor's `return expr` was evaluated and dropped, so `new C()` answered the constructed instance even where Node answers the returned object (`if (C.instance) return C.instance;` built a second instance). A return that yields an instance of the class itself stores it in a per-class result slot, read and cleared by one helper at every construction site (direct new, class values, the checked dynamic construction dispatchers, record-to-class coercion). A return of any other object refuses with SC1090 (compile time in TypeScript, a deferred fence in JavaScript); a JavaScript value typed only at run time is tested when it returns. super() into a class that can return an existing instance refuses, since Node would rebind the subclass's this. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/class-construction.ts | 5 +- .../lowering/class-dynamic-dispatch.ts | 5 +- .../frontend/lowering/coercions/structural.ts | 9 +- .../frontend/lowering/constructor-returns.ts | 377 ++++++++++++++++++ .../src/frontend/lowering/lower-classes.ts | 52 ++- .../src/frontend/lowering/lower-stmts.ts | 6 + .../compiler/src/frontend/lowering/lowerer.ts | 4 + .../constructor-returns-instance.js.json | 6 + .../constructor-returns-instance.ts.json | 6 + ...constructor-returns-instance-super.ts.json | 6 + .../constructor-returns-object.ts.json | 6 + tests/corpus/constructor-returns-instance.js | 45 +++ tests/corpus/constructor-returns-instance.ts | 112 ++++++ .../constructor-returns-instance-super.ts | 15 + .../diagnostics/constructor-returns-object.ts | 10 + .../constructor-returns-instance-super.ts.txt | 6 + .../constructor-returns-object.ts.txt | 6 + tests/harness/errors.test.ts | 52 +++ 18 files changed, 705 insertions(+), 23 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/constructor-returns.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json create mode 100644 tests/corpus/constructor-returns-instance.js create mode 100644 tests/corpus/constructor-returns-instance.ts create mode 100644 tests/diagnostics/constructor-returns-instance-super.ts create mode 100644 tests/diagnostics/constructor-returns-object.ts create mode 100644 tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt create mode 100644 tests/harness/__snapshots__/constructor-returns-object.ts.txt diff --git a/packages/compiler/src/frontend/lowering/class-construction.ts b/packages/compiler/src/frontend/lowering/class-construction.ts index 4dae7ecd0..7f4a003c9 100644 --- a/packages/compiler/src/frontend/lowering/class-construction.ts +++ b/packages/compiler/src/frontend/lowering/class-construction.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { constructedValue } from "./constructor-returns.js"; import { BOOL, DYN, @@ -389,13 +390,13 @@ export class ClassConstructionDispatch { if (!args) continue; const type: IrType = { kind: "classval", className: name }; const callee = varRef("p.0", DYN, loc); - const value: IrExpr = { + const value = constructedValue(lowerer, info, { kind: "newValue", callee: { kind: "dynCheck", value: callee, type, loc }, args, type: { kind: "object", className: name }, loc, - }; + }); const body: IrStmt[] = [ { kind: "return", value: lowerer.coerceToExpected(value, DYN), loc }, ]; diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 120032bf5..16c83e9e9 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { constructedValue } from "./constructor-returns.js"; import { eventTargetApiMembers, eventTargetInheritedMember } from "./lower-event-target.js"; import { BOOL, @@ -3158,13 +3159,13 @@ export class ClassDynamicDispatch { } if (!supported || !info.decl) continue; const cls = classValueRef(lowerer, info, info.decl); - const result: IrExpr = { + const result = constructedValue(lowerer, info, { kind: "new", className: name, args, type: { kind: "object", className: name }, loc, - }; + }); this.constructDispatch.body.unshift({ kind: "if", cond: { diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 925f59d9b..6a5e0058f 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -1,3 +1,4 @@ +import { constructedValue } from "../constructor-returns.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import type { FieldLift } from "./structural-plans.js"; import { buildArrayConversion } from "./builders.js"; @@ -524,7 +525,13 @@ export function recordClassWidthHelper( params: [{ localId: "r.0", name: "r", type: fromT }], returnType: toT, locals: [{ id: "r.0", name: "r", type: fromT, mutable: true }], - body: [{ kind: "return", value: { kind: "new", className, args, type: toT, loc }, loc }], + body: [ + { + kind: "return", + value: constructedValue(lowerer, info, { kind: "new", className, args, type: toT, loc }), + loc, + }, + ], loc, }); return name; diff --git a/packages/compiler/src/frontend/lowering/constructor-returns.ts b/packages/compiler/src/frontend/lowering/constructor-returns.ts new file mode 100644 index 000000000..d054a6b49 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/constructor-returns.ts @@ -0,0 +1,377 @@ +/** Constructors that `return` a value. + * + * JavaScript's `new C()` answers the constructor's returned value when it + * is an object (functions included) and the constructed `this` otherwise. + * A primitive or bare return therefore keeps the ordinary lowering, which + * evaluates the operand and drops it. + * + * A return whose value is an instance of the constructor's own class keeps + * the class representation (`if (Single.instance) return + * Single.instance;`). The return stores that instance in a per-class + * result slot and every direct `new C()` takes it from there. Any other + * object has no representation as a `C` instance and refuses at the return + * statement, deferred to run time in JavaScript like other statement + * fences. A JavaScript value that is an object only at run time (`any`) + * is tested when it returns: a primitive answers the constructed + * instance, an instance of the class takes the result slot, and any other + * object refuses. + * + * `super()` from a subclass refuses: Node would rebind the subclass's + * `this` to the returned instance. */ +import * as ts from "../ts7/adapter.js"; +import { varRef } from "../../ir/build.js"; +import { + BOOL, + DYN, + type IrExpr, + type IrGlobal, + type IrStmt, + type IrType, + STRING, + typeKey, +} from "../../ir/ir.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { ClassInfo } from "./lower-classes.js"; +import type { Lowerer } from "./lowerer.js"; + +type ReturnKind = "instance" | "object" | "mixed"; + +export interface ConstructorReturnPlan { + /** Returns whose value may be an object, by kind. Primitive and `this` + * returns are absent: they keep the ordinary lowering. */ + returns: Map; + /** The result slot (`C | undefined`), present when some return yields an + * instance of the class itself. */ + slot: IrGlobal | null; +} + +const PRIMITIVE_FLAGS = + ts.TypeFlags.Primitive | ts.TypeFlags.EnumLike | ts.TypeFlags.Void | ts.TypeFlags.Never; + +/** Types whose values may be primitives or objects: open types, type + * variables and intersections (a branded `string & { tag }` is a string). */ +const OPEN_FLAGS = + ts.TypeFlags.AnyOrUnknown | ts.TypeFlags.InstantiableNonPrimitive | ts.TypeFlags.Intersection; + +function unparenthesized(node: ts.Expression): ts.Expression { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** Whether a value of this checker type can be an object: "never", + * "always", or "maybe" (open types and primitive/object unions). */ +function objectness(type: ts.Type): "never" | "always" | "maybe" { + const parts = type.isUnionType() ? ts.constituentTypes(type) : [type]; + let primitive = false; + let object = false; + for (const part of parts) { + if ((part.flags & OPEN_FLAGS) !== 0) return "maybe"; + if ((part.flags & PRIMITIVE_FLAGS) !== 0) primitive = true; + else object = true; + } + if (!object) return "never"; + return primitive ? "maybe" : "always"; +} + +/** The constructor's own `return ` statements: nested functions, + * arrows and classes have their own returns. */ +function ownValueReturns(ctor: ts.ConstructorDeclaration): ts.ReturnStatement[] { + const out: ts.ReturnStatement[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) out.push(node); + node.forEachChild(visit); + }; + ctor.body?.forEachChild(visit); + return out; +} + +/** A return inside a `try` with a `finally` block: the finally block runs + * after the result is stored and may throw past it. */ +function insideFinallyTry(node: ts.Node, ctor: ts.ConstructorDeclaration): boolean { + for (let p = node.parent; p !== undefined && p !== ctor; p = p.parent) { + if (ts.isTryStatement(p) && p.finallyBlock !== undefined && !nodeWithin(node, p.finallyBlock)) + return true; + } + return false; +} + +function nodeWithin(node: ts.Node, ancestor: ts.Node): boolean { + return node.pos >= ancestor.pos && node.end <= ancestor.end; +} + +function instanceType(info: ClassInfo): IrType { + return { kind: "object", className: info.def.name }; +} + +function isInstanceOf(lowerer: Lowerer, info: ClassInfo, type: IrType | null): boolean { + if (type === null) return false; + if (type.kind === "object") return type.className === info.def.name; + if (type.kind !== "union") return false; + const arms = lowerer.unions.get(type.unionId)?.arms ?? []; + return ( + arms.length === 2 && + arms.some((arm) => arm.kind === "object" && arm.className === info.def.name) && + arms.some((arm) => arm.kind === "undefinedT") + ); +} + +export function constructorReturnPlan( + lowerer: Lowerer, + info: ClassInfo, +): ConstructorReturnPlan | null { + const byClass = lowerer.constructorReturnPlans; + const known = byClass.get(info.def.name); + if (known !== undefined) return known; + const ctor = info.ctor; + let plan: ConstructorReturnPlan | null = null; + if (ctor?.body !== undefined && info.decl !== null) { + const returns = new Map(); + for (const stmt of ownValueReturns(ctor)) { + const value = unparenthesized(stmt.expression!); + if (value.kind === ts.SyntaxKind.ThisKeyword) continue; + const type = lowerer.typeOf(value); + const shape = objectness(type); + if (shape === "never") continue; + // Local classes construct through their own environment, which has + // no result slot. + const instance = + info.localClass === undefined && + !insideFinallyTry(stmt, ctor) && + isInstanceOf(lowerer, info, lowerer.mapTypeOf(type)); + returns.set(stmt, instance ? "instance" : shape === "always" ? "object" : "mixed"); + } + if (returns.size > 0) { + let slot: IrGlobal | null = null; + // A JavaScript value typed only at run time may hold an instance of + // the class too (`if (C.instance) return C.instance;`). + const runtimeInstance = + info.localClass === undefined && + isJsSourceFile(ctor.getSourceFile()) && + [...returns].some(([stmt, kind]) => kind === "mixed" && !insideFinallyTry(stmt, ctor)); + if ([...returns.values()].includes("instance") || runtimeInstance) { + slot = { + id: `%g.${info.def.name}.%constructorResult`, + name: `${info.def.name}.%constructorResult`, + type: lowerer.withUndefinedArm(instanceType(info)), + mutable: true, + }; + lowerer.globalsList.push(slot); + } + plan = { returns, slot }; + } + } + byClass.set(info.def.name, plan); + return plan; +} + +function foreignObjectFeature(info: ClassInfo): string { + const display = info.def.name.replace(/^%|^%m\d+\./, ""); + return `returning an object other than an instance of the class from a constructor (Node's 'new ${display}()' answers the returned object, which has no representation as a '${display}' instance)`; +} + +/** `return ` inside a class constructor whose value may be an + * object. Null when the statement keeps the ordinary lowering. */ +export function lowerConstructorReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { + const info = lowerer.currentClass; + if (info === null || info.ctor === null || stmt.expression === undefined) return null; + let fn: ts.Node | undefined = stmt.parent; + while (fn !== undefined && !ts.isFunctionLike(fn) && !ts.isClassLikeDeclaration(fn)) + fn = fn.parent; + if (fn !== info.ctor) return null; + const plan = constructorReturnPlan(lowerer, info); + const kind = plan?.returns.get(stmt); + if (plan === null || kind === undefined) return null; + const loc = locOf(stmt); + if (kind === "instance") { + const value = lowerer.coerceInto( + stmt.expression, + lowerer.lowerExpr(stmt.expression), + plan.slot!.type, + ); + return { + kind: "block", + body: [ + { kind: "assign", localId: plan.slot!.id, value, loc }, + { kind: "return", value: null, loc }, + ], + loc, + }; + } + const feature = foreignObjectFeature(info); + if (kind === "object" || !isJsSourceFile(stmt.getSourceFile())) + lowerer.unsupported("SC1090", stmt, feature); + // A JavaScript value that is an object only sometimes: a primitive + // still answers the constructed instance, an object refuses. + const value = lowerer.coerceToExpected(lowerer.lowerExpr(stmt.expression), DYN); + if (value.type.kind !== "dyn") lowerer.unsupported("SC1090", stmt, feature); + const held = lowerer.declareHiddenLocal("%returned", DYN); + const read = varRef(held.id, DYN, loc); + const typeOf: IrExpr = { kind: "libCall", fn: "dyn.typeof", args: [read], type: STRING, loc }; + const typeIs = (name: string): IrExpr => ({ + kind: "strEq", + negated: false, + left: typeOf, + right: { kind: "strLit", value: name, type: STRING, loc }, + type: BOOL, + loc, + }); + const diag = unsupportedDiag("SC1090", loc, feature); + lowerer.runtimeFences.push(diag); + const sf = stmt.getSourceFile(); + const line = ts.getLineAndCharacterOfPosition(sf, loc.start).line + 1; + return { + kind: "block", + body: [ + { kind: "varDecl", localId: held.id, init: value, loc }, + { + kind: "if", + // null answers "object" and is falsy; every object is truthy. + cond: { + kind: "logical", + op: "||", + left: typeIs("function"), + right: { + kind: "logical", + op: "&&", + left: typeIs("object"), + right: lowerer.ensureBool(read, stmt.expression), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + then: [ + ...(plan.slot !== null && !insideFinallyTry(stmt, info.ctor) + ? [ + { + kind: "if" as const, + cond: { + kind: "libCall" as const, + fn: "dyn.typedRefIs" as const, + args: [ + read, + { + kind: "strLit" as const, + value: typeKey(instanceType(info)), + type: STRING, + loc, + }, + ], + type: BOOL, + loc, + }, + then: [ + { + kind: "assign" as const, + localId: plan.slot.id, + value: lowerer.coerceToExpected( + lowerer.coerceToExpected(read, instanceType(info)), + plan.slot.type, + ), + loc, + }, + { kind: "return" as const, value: null, loc }, + ], + else_: null, + loc, + }, + ] + : []), + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: null, loc }, + ], + loc, + }; +} + +/** The value of a construction of exactly class `C`: the constructed + * instance, or the instance the constructor returned. Unchanged for + * classes without a result slot. The slot is read and cleared by a + * per-class helper, so every construction site (direct `new`, class + * values, the checked dynamic dispatchers) shares one protocol. */ +export function constructedValue(lowerer: Lowerer, info: ClassInfo, constructed: IrExpr): IrExpr { + const slot = constructorReturnPlan(lowerer, info)?.slot; + if (slot === null || slot === undefined) return constructed; + const loc = constructed.loc; + const type = instanceType(info); + const name = `%${info.def.name}.%constructed`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const instance = { id: "instance.0", name: "instance", type, mutable: false }; + const returned = { id: "returned.0", name: "returned", type: slot.type, mutable: false }; + lowerer.liftedFns.push({ + name, + params: [{ localId: instance.id, name: instance.name, type }], + returnType: type, + locals: [instance, returned], + body: [ + { kind: "varDecl", localId: returned.id, init: varRef(slot.id, slot.type, loc), loc }, + { kind: "assign", localId: slot.id, value: lowerer.wrappedUndefined(slot.type, loc)!, loc }, + { + kind: "return", + value: { + kind: "orDefault", + left: varRef(returned.id, slot.type, loc), + right: varRef(instance.id, type, loc), + type, + loc, + }, + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [constructed], type, loc }; +} + +/** A construction through a class value whose static class is `info`: the + * value holds `info` itself or a descendant. Descendants of a class with a + * result slot refuse at their `super()`, so only `info`'s own slot can + * apply; a descendant with its own slot has no static home here. */ +export function constructedFromValue( + lowerer: Lowerer, + info: ClassInfo, + constructed: IrExpr, + blame: ts.Node, +): IrExpr { + const pending = [...info.subclasses]; + while (pending.length > 0) { + const sub = pending.pop()!; + pending.push(...sub.subclasses); + const plan = constructorReturnPlan(lowerer, sub); + if (plan === null || plan.slot === null) continue; + const display = sub.def.name.replace(/^%|^%m\d+\./, ""); + lowerer.unsupported( + "SC1090", + blame, + `constructing through a class value that may hold '${display}', whose constructor can return an existing instance`, + ); + } + return constructedValue(lowerer, info, constructed); +} + +/** `super()` into a class with a result slot: Node rebinds the subclass's + * `this` to the returned instance, which has no representation. */ +export function refuseSuperIntoResultSlot(lowerer: Lowerer, base: ClassInfo, blame: ts.Node): void { + const plan = constructorReturnPlan(lowerer, base); + if (plan === null || plan.slot === null) return; + const display = base.def.name.replace(/^%|^%m\d+\./, ""); + lowerer.unsupported( + "SC1090", + blame, + `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`, + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f3a4830c3..8bb3fd8cb 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,5 +1,10 @@ import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js"; import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; +import { + constructedFromValue, + constructedValue, + refuseSuperIntoResultSlot, +} from "./constructor-returns.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { lowerReadlineUiNew } from "./builtins/readline.js"; import { collectClassMethodMutations } from "./class-method-mutations.js"; @@ -8020,6 +8025,7 @@ export function superCallStmt( info.decl ?? info.ctor!, )[0]!; } + refuseSuperIntoResultSlot(lowerer, base, info.ctor ?? info.decl!); lowerer.noteSuperConstructorEdge(`%${base.def.name}.constructor`); return { kind: "exprStmt", @@ -8429,21 +8435,21 @@ function lowerProgramClassNew( : lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); for (let current: ClassInfo | null = info; current; current = current.base) { if (current.def.baseValueGlobal) - return { + return constructedValue(lowerer, info, { kind: "newValue", callee: classValueRef(lowerer, info, expr), args, type: { kind: "object", className: info.def.name }, loc, - }; + }); } - return { + return constructedValue(lowerer, info, { kind: "new", className: info.def.name, args, type: { kind: "object", className: info.def.name }, loc, - }; + }); } function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { @@ -8731,13 +8737,18 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { below(info); const callee: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc }; const args = lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); - return { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }; + return constructedFromValue( + lowerer, + info, + { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, + expr, + ); } } } @@ -9638,13 +9649,18 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { }; below(info); const args = lowerer.completeArgs(expr.arguments ?? [], info.ctorParams, loc, expr); - return { - kind: "newValue", - callee, - args, - type: { kind: "object", className: info.def.name }, - loc, - }; + return constructedFromValue( + lowerer, + info, + { + kind: "newValue", + callee, + args, + type: { kind: "object", className: info.def.name }, + loc, + }, + expr, + ); } if (calleeT?.kind === "union") { const def = lowerer.unions.get(calleeT.unionId); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 12544979c..9ede6182b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -14,6 +14,7 @@ import { processDataMemberValue } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; import { bindingSource } from "../binding-source.js"; +import { lowerConstructorReturn } from "./constructor-returns.js"; import type { Lowerer } from "./lowerer.js"; import { arrayValueRead, @@ -1370,6 +1371,11 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt // incompatible boundary keeps the ordinary per-statement refusal. let value: IrExpr | null = null; if (stmt.expression) { + // A constructor's returned object replaces the constructed `this`. + if (lowerer.currentClass?.ctor) { + const constructorReturn = lowerConstructorReturn(lowerer, stmt); + if (constructorReturn !== null) return constructorReturn; + } return lowerer.lowerReturnStmt(stmt.expression, locOf(stmt)); } else if (lowerer.ctx.returnType.kind === "union") { value = lowerer.wrappedUndefined(lowerer.ctx.returnType, locOf(stmt)); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 145fb3ae6..cdc170364 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4,6 +4,7 @@ import { lowerLifetimeRegionCall, lowerLifetimeRegionNew, } from "./lifetime-regions.js"; +import type { ConstructorReturnPlan } from "./constructor-returns.js"; import { internTableType, lowerInternTableCall, lowerInternTableNew } from "./intern-tables.js"; import { isExperimentalDeclarationFile } from "../experimental-import.js"; import { @@ -2706,6 +2707,9 @@ export class Lowerer { * by class name (cycle-initialization.ts). */ readonly classInitFlags = new Map(); readonly cycleInitDeclarations = new Map(); + /** Each class's constructor return plan by class name + * (constructor-returns.ts); null when no return can yield an object. */ + readonly constructorReturnPlans = new Map(); /** CommonJS require declarations whose bindings code running before the * require can read, and those references (require-initialization.ts). */ readonly requireInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json new file mode 100644 index 000000000..a85c33c1a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-instance.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json new file mode 100644 index 000000000..e01b6a426 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-instance.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-instance.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json new file mode 100644 index 000000000..86b15f680 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-instance-super.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/constructor-returns-instance-super.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json new file mode 100644 index 000000000..f3415a937 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/constructor-returns-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/constructor-returns-object.ts" + ], + "diags": [] +} diff --git a/tests/corpus/constructor-returns-instance.js b/tests/corpus/constructor-returns-instance.js new file mode 100644 index 000000000..ab7af8b2c --- /dev/null +++ b/tests/corpus/constructor-returns-instance.js @@ -0,0 +1,45 @@ +// JavaScript constructors whose returned value is typed only at run time: +// an instance of the class replaces `this`, a primitive keeps it. +class Connection { + constructor(url) { + if (Connection.open && Connection.open.url === url) return Connection.open; + this.url = url; + this.uses = 0; + Connection.open = this; + } + use() { + this.uses++; + return this; + } +} +const c1 = new Connection("db://a").use(); +const c2 = new Connection("db://a").use(); +const c3 = new Connection("db://b").use(); +console.log(c1 === c2, c2.uses, c3 === c1, c3.uses, c3.url); + +class Counter { + constructor(start, answer) { + this.count = start; + return answer; + } +} +console.log(new Counter(1, 5).count, new Counter(2, "x").count, new Counter(3, null).count); +console.log(new Counter(4, undefined).count, new Counter(5, true).count); +const kept = new Counter(6, 0); +console.log(new Counter(7, kept) === kept, kept.count); + +const memoCache = new Map(); +let memoCreated = 0; +class Memo { + constructor(key) { + const hit = memoCache.get(key); + if (hit !== undefined) return hit; + this.key = key; + this.created = ++memoCreated; + memoCache.set(key, this); + } +} +const m1 = new Memo("k"); +const m2 = new Memo("k"); +const m3 = new Memo("j"); +console.log(m1 === m2, m3 === m1, m2.created, m3.created, memoCreated); diff --git a/tests/corpus/constructor-returns-instance.ts b/tests/corpus/constructor-returns-instance.ts new file mode 100644 index 000000000..81e25a12d --- /dev/null +++ b/tests/corpus/constructor-returns-instance.ts @@ -0,0 +1,112 @@ +// A constructor's returned object replaces the constructed instance when it +// is an object; primitive, bare and `this` returns answer the instance. +class Registry { + static current: Registry | undefined = undefined; + readonly id: number = 0; + entries: string[] = []; + constructor(id: number) { + if (Registry.current) return Registry.current; + this.id = id; + Registry.current = this; + } + add(entry: string): this { + this.entries.push(entry); + return this; + } +} +const first = new Registry(1).add("a"); +const second = new Registry(2).add("b"); +console.log(first === second, second.id, second.entries.join(",")); + +// An instance returned only on some paths. +class Slot { + static previous: Slot | undefined = undefined; + label: string; + constructor(label: string, reuse: boolean) { + this.label = label; + const previous = Slot.previous; + Slot.previous = this; + if (reuse && previous) return previous; + } +} +const s1 = new Slot("one", true); +const s2 = new Slot("two", true); +const s3 = new Slot("three", false); +console.log(s1.label, s2.label, s3.label, s2 === s1, s3 === s2); + +// Constructing the class again inside its own constructor. +class Node2 { + static made = 0; + value: number; + child: Node2 | null = null; + constructor(depth: number, share: Node2 | null) { + Node2.made++; + this.value = depth; + if (share !== null) return share; + if (depth > 0) this.child = new Node2(depth - 1, null); + } +} +const root = new Node2(2, null); +const alias = new Node2(9, root); +console.log(alias === root, alias.value, root.child?.value, root.child?.child?.value, Node2.made); + +// A constructor that throws leaves later constructions unaffected. +class Guarded { + static kept: Guarded | undefined = undefined; + n: number; + constructor(n: number) { + if (n < 0) throw new RangeError(`negative: ${n}`); + this.n = n; + if (n === 0 && Guarded.kept) return Guarded.kept; + Guarded.kept = this; + } +} +try { + new Guarded(-1); +} catch (e) { + console.log((e as Error).message); +} +const g1 = new Guarded(5); +const g2 = new Guarded(0); +const g3 = new Guarded(7); +console.log(g1.n, g2.n, g3.n, g1 === g2, g3 === g1); + +// Primitive, bare and `this` returns keep the constructed instance. +class Plain { + v = 3; + constructor(mode: number) { + if (mode === 1) return; + if (mode === 2) return this; + } +} +const plain = new Plain(2); +console.log(new Plain(0).v, new Plain(1).v, plain.v, plain instanceof Plain); + +// The derived class's own constructor may return an instance of itself. +class Animal { + sound = "..."; +} +class Dog extends Animal { + static pack: Dog[] = []; + name: string; + constructor(name: string) { + super(); + this.name = name; + this.sound = "woof"; + const known = Dog.pack.find((d) => d.name === name); + if (known) return known; + Dog.pack.push(this); + } +} +const rex = new Dog("rex"); +const again = new Dog("rex"); +const fido = new Dog("fido"); +console.log(rex === again, fido === rex, Dog.pack.length, again.sound, again instanceof Animal); + +// Constructions through class values take the returned instance too. +const Ctor: typeof Registry = Registry; +console.log(new Ctor(3) === first); +function build(make: new (id: number) => T, id: number): T { + return new make(id); +} +console.log(build(Registry, 4) === first, build(Registry, 5).entries.length); diff --git a/tests/diagnostics/constructor-returns-instance-super.ts b/tests/diagnostics/constructor-returns-instance-super.ts new file mode 100644 index 000000000..c5e34c7a1 --- /dev/null +++ b/tests/diagnostics/constructor-returns-instance-super.ts @@ -0,0 +1,15 @@ +// `super()` into a constructor that can return an existing instance would +// rebind the subclass's `this` to that instance. +class Shared { + static one: Shared | undefined = undefined; + constructor() { + if (Shared.one) return Shared.one; + Shared.one = this; + } +} +class Derived extends Shared { + constructor() { + super(); + } +} +console.log(new Derived() instanceof Derived); diff --git a/tests/diagnostics/constructor-returns-object.ts b/tests/diagnostics/constructor-returns-object.ts new file mode 100644 index 000000000..63abf4c4d --- /dev/null +++ b/tests/diagnostics/constructor-returns-object.ts @@ -0,0 +1,10 @@ +// Node's `new Point()` answers the object a constructor returns. A plain +// object has no representation as a Point instance, so the return refuses +// instead of answering the constructed instance. +class Point { + x = 1; + constructor() { + return { x: 2 }; + } +} +console.log(new Point().x); diff --git a/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt b/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt new file mode 100644 index 000000000..53a01d431 --- /dev/null +++ b/tests/harness/__snapshots__/constructor-returns-instance-super.ts.txt @@ -0,0 +1,6 @@ +constructor-returns-instance-super.ts:11:3 - error SC1090: extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance 'Shared' returns) is not supported yet + + 10 | class Derived extends Shared { + 11 | constructor() { + | ^~~~~~~~~~~~~~~ + 12 | super(); \ No newline at end of file diff --git a/tests/harness/__snapshots__/constructor-returns-object.ts.txt b/tests/harness/__snapshots__/constructor-returns-object.ts.txt new file mode 100644 index 000000000..fe2220d56 --- /dev/null +++ b/tests/harness/__snapshots__/constructor-returns-object.ts.txt @@ -0,0 +1,6 @@ +constructor-returns-object.ts:7:5 - error SC1090: returning an object other than an instance of the class from a constructor (Node's 'new Point()' answers the returned object, which has no representation as a 'Point' instance) is not supported yet + + 6 | constructor() { + 7 | return { x: 2 }; + | ^~~~~~~~~~~~~~~~ + 8 | } \ No newline at end of file diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 51f04d310..856c1ad7d 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1013,3 +1013,55 @@ console.log(typeof Derived); ); }); }); + +describe("constructors returning objects (JS lane)", () => { + test("a returned plain object is a named deferred fence", async () => { + // Node's `new Box(2)` answers `{ size: 99 }`; a Box instance cannot + // hold that object, so the return refuses instead of answering `this`. + const r = await compileAndRun( + "constructor-returns-object", + `'use strict'; +class Box { + constructor(size) { + this.size = size; + if (size > 1) return { size: 99 }; + } +} +console.log(new Box(1).size); +console.log(new Box(2)); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("1\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: returning an object other than an instance of the class from a constructor \(Node's 'new Box\(\)' answers the returned object, .*\) is not supported yet \[SC1090 at .*constructor-returns-object\.cjs:5\]\n$/, + ); + }); + + test("an untyped returned value fences only when it holds a foreign object", async () => { + // Primitive results answer the constructed instance, as in Node; an + // object that is not a Wrap instance refuses. + const r = await compileAndRun( + "constructor-returns-untyped", + `'use strict'; +class Wrap { + constructor(value) { + this.value = value; + return value; + } +} +console.log(new Wrap(1).value, new Wrap('s').value, new Wrap(null).value); +const w = new Wrap(0); +console.log(new Wrap(w) === w); +console.log(new Wrap({ a: 1 })); +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("1 s null\ntrue\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: returning an object other than an instance of the class from a constructor .* is not supported yet \[SC1090 at .*constructor-returns-untyped\.cjs:5\]\n$/, + ); + }); +}); From af0c7f6a8c4d2c66b510131d17aae6ff843aa4da Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:30 -0700 Subject: [PATCH 007/127] Answer the right operand of && over an always-truthy left `list && list[0]` with an array, object or function on the left and a union result (the element read may be undefined) coerced the left operand into that union, which traps at run time ("a 'number[]' value is not representable in the target union") where Node answers the element. Every object is truthy: evaluate the left for its effects and answer the right. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 19 ++++++++ .../corpus/logical-and-truthy-index.ts.json | 6 +++ tests/corpus/logical-and-truthy-index.ts | 44 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json create mode 100644 tests/corpus/logical-and-truthy-index.ts diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 09296bdfe..d3deaa719 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -11399,6 +11399,25 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE } } } + // `&&` over an always-truthy plain left operand (an array, object or + // function value) whose result union has no arm for it: every JS + // object is truthy, so `list && list[0]` evaluates the left for its + // effects and answers the right. Coercing the left into that union + // would trap at run time where Node answers the right operand. + if ( + op === ts.SyntaxKind.AmpersandAmpersandToken && + target?.kind === "union" && + REF_TRUTHY_KINDS.has(left.type.kind) && + lowerer.armTag(target.unionId, left.type) < 0 + ) { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: lowerer.coerceInto(expr.right, right, target), + type: target, + loc, + }; + } if (target?.kind === "union") { // `&&` whose left does NOT fit the result union: the checker // built that result by DROPPING left arms which are always diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json new file mode 100644 index 000000000..2e7388984 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/logical-and-truthy-index.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/logical-and-truthy-index.ts" + ], + "diags": [] +} diff --git a/tests/corpus/logical-and-truthy-index.ts b/tests/corpus/logical-and-truthy-index.ts new file mode 100644 index 000000000..b89e4d78d --- /dev/null +++ b/tests/corpus/logical-and-truthy-index.ts @@ -0,0 +1,44 @@ +// `&&` whose left operand is an array, object or function value: every +// object is truthy, so the result is the right operand, here an element +// read that may be undefined at run time. +const list = [3, 1, 2]; +const head = list && list[0]; +console.log(head); +const words: string[] = ["x"]; +console.log(words && words[0], words && words[5]); +const empty: number[] = []; +console.log(empty && empty[0]); +function first(xs: (string | number)[]): string | number { + return xs && xs[0]; +} +console.log(first([1]), first(["s"])); +type Item = { n: number } | string; +const items: Item[] = [{ n: 1 }, "z"]; +console.log(items && items[0], items && items[1]); +const nested: number[][] = [[1]]; +console.log(nested && nested[0]); +const flags: boolean[] = [false]; +console.log(flags && flags[0]); +const maybe: (number | null)[] = [null]; +console.log(maybe && maybe[0]); +const holes: (number | undefined)[] = [undefined, 4]; +console.log(holes && holes[0], holes && holes[1]); +const box = { k: [1, 2] }; +console.log(box.k && box.k[1]); +// The left operand still evaluates exactly once. +let reads = 0; +const source = (): number[] => { + reads++; + return [7, 8]; +}; +console.log(source() && source()[1], reads); +const table: Record = { a: [5] }; +const pick = (key: string): number | undefined => table[key] && table[key][0]; +console.log(pick("a"), reads); +class Holder { + xs = [1, 2]; + second(): number | undefined { + return this.xs && this.xs[1]; + } +} +console.log(new Holder().second()); From 89bca68cbe13a4aca3d66e9ea1ad5746063b9e99 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:25:11 -0700 Subject: [PATCH 008/127] Compute with NaN-boxed jsval values in the dynamic tier Stage S3 of the two-tier design: dynamic-tier functions compute with jsval, a new IR type holding a 64-bit NaN-boxed word whose numbers, booleans, undefined and null are immediates, so a number between two dynamic operators is no longer a heap node. - IR: the jsval type and js.* builtins (operators and the boundary conversions fromDyn/toDyn/fromF64/fromBool/fromStr/fromBigInt/toF64). The jsval pass rewrites dynamic-tier functions after tier assignment: operator chains, comparisons, number checks and truthiness of operator results run on jsvals; dyn locals become jsval locals when that saves boxing; dynamic functions called only from dynamic functions return a jsval. IR version 17 serializes each function's tier. - Runtime: scr_js.h and scr_js_value.c behind the dynTier feature (invariant I3); static runtime units never include scr_js.h. - Backend: number cases inline (immediate doubles and NUM cells), always inlined jsval retain/release; 32-bit targets keep boxed cells. - Interprocedural analyses see jsval conversions as the dyn forms they replace, so no fact about a static function changes. Static functions are untouched: typed programs and the self-hosted compiler's static functions compile to byte-identical LLVM. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/cycle-analysis.test.ts | 2 +- .../compiler/src/backend/cycle-analysis.ts | 3 +- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/immutability.test.ts | 2 +- packages/compiler/src/backend/library-pack.ts | 1 + .../llvm/appended-array-literals.test.ts | 2 +- .../src/backend/llvm/array-search.test.ts | 2 +- .../src/backend/llvm/checked-narrows.test.ts | 2 +- .../compiler/src/backend/llvm/classes.test.ts | 14 +- .../backend/llvm/constant-callbacks.test.ts | 2 +- .../backend/llvm/dense-array-access.test.ts | 2 +- .../compiler/src/backend/llvm/emitter.test.ts | 6 +- packages/compiler/src/backend/llvm/emitter.ts | 3 + .../src/backend/llvm/expr-async.test.ts | 2 +- .../src/backend/llvm/expr-calls.test.ts | 2 +- .../src/backend/llvm/expr-primitives.test.ts | 4 +- .../backend/llvm/expr-stream-bridges.test.ts | 4 +- .../src/backend/llvm/int32-abi.test.ts | 4 +- .../src/backend/llvm/integer-clones.test.ts | 6 +- .../src/backend/llvm/intern-tables.test.ts | 2 +- .../src/backend/llvm/jsval-encoding.ts | 21 + packages/compiler/src/backend/llvm/jsval.ts | 575 ++++++++++++++++++ .../compiler/src/backend/llvm/lib-dispatch.ts | 3 + .../backend/llvm/local-array-reads.test.ts | 2 +- .../backend/llvm/local-union-storage.test.ts | 4 +- .../src/backend/llvm/map-entry-fusion.test.ts | 2 +- .../backend/llvm/map-number-lookup.test.ts | 4 +- .../backend/llvm/map-read-lifetimes.test.ts | 4 +- .../compiler/src/backend/llvm/publish.test.ts | 2 +- .../src/backend/llvm/reference-effects.ts | 8 + .../src/backend/llvm/reference-writes.ts | 32 + .../compiler/src/backend/llvm/regions.test.ts | 2 +- packages/compiler/src/backend/llvm/regions.ts | 1 + .../compiler/src/backend/llvm/shapes.test.ts | 6 +- packages/compiler/src/backend/llvm/shapes.ts | 87 ++- .../src/backend/llvm/stack-objects.test.ts | 2 +- .../src/backend/llvm/stack-unions.test.ts | 2 +- .../backend/llvm/string-construction.test.ts | 2 +- .../src/backend/llvm/string-lifetimes.test.ts | 2 +- .../src/backend/llvm/switch-dispatch.test.ts | 4 +- .../src/backend/llvm/union-retype.test.ts | 2 +- .../compiler/src/backend/may-throw.test.ts | 2 +- .../src/backend/native-link-info-core.ts | 3 + .../compiler/src/backend/native/contracts.ts | 6 + .../compiler/src/backend/native/executable.ts | 1 + .../compiler/src/backend/native/library.ts | 1 + .../compiler/src/backend/publication.test.ts | 2 +- .../compiler/src/backend/runtime-pack-core.ts | 2 + .../src/backend/target-diagnostics.test.ts | 2 +- .../src/executable/executable-cache.ts | 1 + .../compiler/src/frontend/lowering/lowerer.ts | 7 +- .../lowering/sanitize-class-types.test.ts | 4 +- packages/compiler/src/frontend/type-mapper.ts | 1 + packages/compiler/src/index.ts | 3 + packages/compiler/src/ir/analysis.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 10 + .../compiler/src/ir/builtin-signatures.ts | 24 + .../src/ir/callback-specialization.test.ts | 2 +- .../compiler/src/ir/constant-globals.test.ts | 2 +- .../compiler/src/ir/constant-tables.test.ts | 2 +- packages/compiler/src/ir/field-values.test.ts | 2 +- packages/compiler/src/ir/final-fields.test.ts | 2 +- .../src/ir/function-references.test.ts | 2 +- .../compiler/src/ir/init-only-globals.test.ts | 2 +- .../src/ir/initializer-bindings.test.ts | 2 +- packages/compiler/src/ir/int32-slots.test.ts | 2 +- packages/compiler/src/ir/int32-slots.ts | 15 +- packages/compiler/src/ir/ir.ts | 18 +- packages/compiler/src/ir/jsval.test.ts | 279 +++++++++ packages/compiler/src/ir/jsval.ts | 384 ++++++++++++ packages/compiler/src/ir/runtime-features.ts | 12 + .../compiler/src/ir/scalar-records.test.ts | 2 +- packages/compiler/src/ir/serialize.ts | 22 +- packages/compiler/src/ir/tiers.test.ts | 68 ++- packages/compiler/src/ir/tiers.ts | 32 +- packages/compiler/src/ir/traverse.ts | 1 + packages/compiler/src/ir/unshare.test.ts | 2 +- packages/compiler/src/ir/validate.test.ts | 12 +- .../compiler/src/library/fence-eval.test.ts | 2 +- .../compiler/src/library/int-infer.test.ts | 8 +- .../src/library/library-cache.test.ts | 10 +- .../compiler/src/library/library-cache.ts | 1 + packages/compiler/src/native/compiler.test.ts | 4 +- .../compiler/src/native/sanitizer.test.ts | 1 + .../test/bytes-element-emission.test.ts | 8 +- packages/compiler/test/emit-llvm.test.ts | 6 +- packages/compiler/test/fixtures/fib-ir.ts | 2 +- .../test/fromcharcode-emission.test.ts | 2 +- packages/compiler/test/ir.test.ts | 4 +- .../compiler/test/llvm-lib-dispatch.test.ts | 2 +- .../compiler/test/llvm-runtime-abi.test.ts | 11 +- .../test/string-accumulation-emission.test.ts | 2 +- .../tests/corpus/6146-jsval-operators.js.json | 6 + .../corpus/6147-jsval-any-operators.ts.json | 6 + .../runtime-pack-matrix.mjs | 3 + .../runtime-pack-common/library-matrix.mjs | 1 + .../runtime-pack-matrix.mjs | 3 + packages/runtime/src/scr_js.h | 183 ++++++ packages/runtime/src/scr_js_value.c | 318 ++++++++++ packages/runtime/test/headers.test.ts | 13 +- tests/corpus/6146-jsval-operators.js | 151 +++++ tests/corpus/6147-jsval-any-operators.ts | 48 ++ tests/fixtures/self-hosting/class-types.ts | 2 +- tests/harness/self-hosting-validator-cases.ts | 2 +- 104 files changed, 2425 insertions(+), 142 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/jsval-encoding.ts create mode 100644 packages/compiler/src/backend/llvm/jsval.ts create mode 100644 packages/compiler/src/ir/jsval.test.ts create mode 100644 packages/compiler/src/ir/jsval.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6146-jsval-operators.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6147-jsval-any-operators.ts.json create mode 100644 packages/runtime/src/scr_js.h create mode 100644 packages/runtime/src/scr_js_value.c create mode 100644 tests/corpus/6146-jsval-operators.js create mode 100644 tests/corpus/6147-jsval-any-operators.ts diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index 8972b9e70..c78420f73 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -21,7 +21,7 @@ import { computeTraced as llvmTraced } from "./llvm/shapes.js"; const loc = { file: "cycles.ts", start: 0, end: 1 }; function module(): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", locals: [], params: [], returnType: VOID, body: [], loc }], diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index 22eb4c658..c41b88067 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -40,7 +40,7 @@ export function computeTraced(mod: IrModule): { shapes: Set; unions: Set } /** Values that can reference any object. */ -export type IntrinsicKind = "func" | "dyn" | "classval" | "promise" | "caught"; +export type IntrinsicKind = "func" | "dyn" | "jsval" | "classval" | "promise" | "caught"; /** Why an edge of the cycle graph exists (explanations only). */ export type CycleEdgeLabel = @@ -218,6 +218,7 @@ function analyzeCycles( switch (t.kind) { case "func": case "dyn": + case "jsval": case "classval": case "promise": case "caught": diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 553643470..42673a019 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -20,6 +20,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { dynInvoke: features.dynInvoke, dc: features.dc, dynAsync: features.dynAsync, + ...(features.dynTier ? { dynTier: true } : {}), events: features.processEvents, emitter: features.emitter, symbol: features.symbol, diff --git a/packages/compiler/src/backend/immutability.test.ts b/packages/compiler/src/backend/immutability.test.ts index 7f5e22fb5..a4e82cefe 100644 --- a/packages/compiler/src/backend/immutability.test.ts +++ b/packages/compiler/src/backend/immutability.test.ts @@ -48,7 +48,7 @@ function module( functions: IrModule["functions"], ): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", classes, diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts index 833ef93e7..554c40524 100644 --- a/packages/compiler/src/backend/library-pack.ts +++ b/packages/compiler/src/backend/library-pack.ts @@ -33,6 +33,7 @@ export async function compilePackedLibrary( assert: options.assert ?? false, inspect: options.inspect ?? false, dynInvoke: options.dynInvoke ?? false, + ...(options.dynTier === true ? { dynTier: true } : {}), dc: false, dynAsync: false, events: false, diff --git a/packages/compiler/src/backend/llvm/appended-array-literals.test.ts b/packages/compiler/src/backend/llvm/appended-array-literals.test.ts index ada57a831..c483b7848 100644 --- a/packages/compiler/src/backend/llvm/appended-array-literals.test.ts +++ b/packages/compiler/src/backend/llvm/appended-array-literals.test.ts @@ -22,7 +22,7 @@ const push = (receiver: IrExpr): IrStmt => ({ function module(body: IrStmt[], locals: string[]): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [ diff --git a/packages/compiler/src/backend/llvm/array-search.test.ts b/packages/compiler/src/backend/llvm/array-search.test.ts index 8b644f708..e6d367710 100644 --- a/packages/compiler/src/backend/llvm/array-search.test.ts +++ b/packages/compiler/src/backend/llvm/array-search.test.ts @@ -26,7 +26,7 @@ function module(elem: IrType, method: "indexOf" | "includes"): IrModule { loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [ diff --git a/packages/compiler/src/backend/llvm/checked-narrows.test.ts b/packages/compiler/src/backend/llvm/checked-narrows.test.ts index 6034d578c..665b4cb44 100644 --- a/packages/compiler/src/backend/llvm/checked-narrows.test.ts +++ b/packages/compiler/src/backend/llvm/checked-narrows.test.ts @@ -150,7 +150,7 @@ function fixture(helper: IrFunction): IrModule { ], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 309d8f071..33c52a9f3 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -16,7 +16,7 @@ const loc = { file: "class-membership.ts", start: 0, end: 1 }; test("leaf layouts keep direct teardown while recursive layouts share the depth budget", () => { const linked = { kind: "object" as const, className: "Linked" }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], @@ -63,7 +63,7 @@ test("releases split into an inlinable fast path and an out-of-line remainder", // An array of the class itself makes the hierarchy cycle-capable. const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], @@ -92,7 +92,7 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } }; const owners = { kind: "array" as const, elem: { kind: "object" as const, className: "Owner" } }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], @@ -144,7 +144,7 @@ test("published ProgramLifetime instances use the published mark every other obj const leaf = { kind: "object" as const, className: "Leaf" }; const root = { localId: "root", name: "root", type: node }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -236,7 +236,7 @@ function graph() { { name: "FlippedString", base: "PairString", genericOf: "Flipped" }, ]; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [], @@ -270,7 +270,7 @@ test.each(["i32", "i64"])( expect(helper).toContain("label %ordinary"); expect(helper).toContain("or i1"); const ordinary: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [], @@ -285,7 +285,7 @@ test.each(["i32", "i64"])( test("unconstructed narrowing layouts may omit abstract slots while live classes require implementations", () => { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts index 071d8e1ff..aa491c711 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts @@ -71,7 +71,7 @@ function fixture(): IrModule { loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [predicate, consumer, entry], diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index 31680a518..6da427841 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -31,7 +31,7 @@ function module( unions: IrModule["unions"] = [], ): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", unions, diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 74dc8f859..f413d6153 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -71,7 +71,7 @@ function moduleFor( loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -336,7 +336,7 @@ const text = (obj: IrExpr): IrExpr => ({ function work(expr: IrExpr, parameter: IrType, boxed = false, tdz = false): string { const module: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: receiverLoc.file, entry: "main", records: [ @@ -547,7 +547,7 @@ function sharedFieldModule(prefixes: IrType[][], fieldType: IrType): IrModule { ], })); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2cccda19c..59d133348 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,3 +1,4 @@ +import { jsTruthy } from "./jsval.js"; import { type ArraySearchKind, emitArraySearchHelpers } from "./array-search.js"; import { emitStringInputs } from "./string-lifetimes.js"; import { emitLlvmLayouts } from "./layouts.js"; @@ -5736,6 +5737,8 @@ export class LlEmitter { B.line(`${truthy} = call zeroext i1 @scr_dyn_truthy(ptr ${valueName})`); return truthy; } + case "jsval": + return jsTruthy(this, valueName); default: throw new LlvmUnsupportedError(`truthy:${unionArm ? "union:" : ""}${kind}`); } diff --git a/packages/compiler/src/backend/llvm/expr-async.test.ts b/packages/compiler/src/backend/llvm/expr-async.test.ts index 0388e9a1c..46418d332 100644 --- a/packages/compiler/src/backend/llvm/expr-async.test.ts +++ b/packages/compiler/src/backend/llvm/expr-async.test.ts @@ -17,7 +17,7 @@ const concat = (left: IrExpr, right: IrExpr): IrExpr => ({ function logBody(name: "console.log" | "console.error", args: IrExpr[]): string { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/expr-calls.test.ts b/packages/compiler/src/backend/llvm/expr-calls.test.ts index 884cfda8c..5c0427351 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.test.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.test.ts @@ -79,7 +79,7 @@ function apply(arg: "param" | "element"): IrFunction { function moduleWith(functions: IrFunction[], main: IrStmt[] = []): IrModule { const store = local("store", strings, { mutable: true, boxed: true }); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/expr-primitives.test.ts b/packages/compiler/src/backend/llvm/expr-primitives.test.ts index 9ab749a3e..1790854ef 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.test.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.test.ts @@ -26,7 +26,7 @@ function fixture(): IrModule { loc, }); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [{ name: "__main", params: [], returnType: VOID, locals: [], body, loc }], @@ -121,7 +121,7 @@ test("reference identity and instanceof borrow their operands", () => { body: [{ kind: "return" as const, value, loc }], }); const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", classes: [ diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts index 54c93c163..5d8f5f26f 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts @@ -20,7 +20,7 @@ function sharedGraph(roots: number): IrModule { unionId: `input${index}`, })); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [ @@ -161,7 +161,7 @@ test("class instance capsules bind prototype member names by exact class", () => const loc = { file: "live-references.ts", start: 0, end: 0 }; const base: IrType = { kind: "object", className: "Base" }; const module: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", classes: [ diff --git a/packages/compiler/src/backend/llvm/int32-abi.test.ts b/packages/compiler/src/backend/llvm/int32-abi.test.ts index 0dad7a998..a21056fd3 100644 --- a/packages/compiler/src/backend/llvm/int32-abi.test.ts +++ b/packages/compiler/src/backend/llvm/int32-abi.test.ts @@ -48,7 +48,7 @@ function fn(name: string, params: string[], body: IrStmt[], returnType = F64): I function module(): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -158,7 +158,7 @@ function unionModule(): IrModule { loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", unions: [{ id: "optional", arms: [F64, UNDEFINED_T] }], diff --git a/packages/compiler/src/backend/llvm/integer-clones.test.ts b/packages/compiler/src/backend/llvm/integer-clones.test.ts index 72c9a1c85..c331fe458 100644 --- a/packages/compiler/src/backend/llvm/integer-clones.test.ts +++ b/packages/compiler/src/backend/llvm/integer-clones.test.ts @@ -85,7 +85,7 @@ const fib = fn( function module(): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -265,7 +265,7 @@ test("sums the static integer ranges prove skip the overflow check", () => { // n is within [0, 10], so n - 1 cannot overflow; the accumulated sum is // still checked. const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -330,7 +330,7 @@ test("borrowed number arrays: length and element reads qualify", () => { F64, [arr, F64], ); - const mod: IrModule = { irVersion: 16, sourceFile: loc.file, entry: "sum", functions: [sum] }; + const mod: IrModule = { irVersion: 17, sourceFile: loc.file, entry: "sum", functions: [sum] }; expect(findIntegerClones(mod).members.has("sum")).toBe(false); expect(findIntegerClones(mod, () => false).members.has("sum")).toBe(false); const clones = findIntegerClones(mod, (name, index) => name === "sum" && index === 0); diff --git a/packages/compiler/src/backend/llvm/intern-tables.test.ts b/packages/compiler/src/backend/llvm/intern-tables.test.ts index 2c26c7a8f..ce571cc49 100644 --- a/packages/compiler/src/backend/llvm/intern-tables.test.ts +++ b/packages/compiler/src/backend/llvm/intern-tables.test.ts @@ -7,7 +7,7 @@ const loc = { file: "intern-tables.ts", start: 0, end: 1 }; function module(body: IrExpr[]): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/jsval-encoding.ts b/packages/compiler/src/backend/llvm/jsval-encoding.ts new file mode 100644 index 000000000..58c0dd68e --- /dev/null +++ b/packages/compiler/src/backend/llvm/jsval-encoding.ts @@ -0,0 +1,21 @@ +/* jsval word encoding constants shared by the jsval emitter (jsval.ts) and + * the RC helpers (shapes.ts). The authoritative description is scr_js.h. */ + +/** Bit patterns (decimal i64 literals; unsigned compares read them as + * the 0xFFF9... words). */ +export const JS_BITS = { + /** Words below this are doubles; it is also undefined's word. */ + limit: "-1970324836974592", + undefined: "-1970324836974592", + null: "-1970324836974591", + false: "-1970324836974590", + true: "-1970324836974589", + cellTag: "-1688849860263936", + strTag: "-1407374883553280", + bigintTag: "-1125899906842624", + payloadMask: "281474976710655", + canonicalNan: "9221120237041090560", +} as const; + +/** The tag of a word (its top 16 bits) for each heap kind. */ +export const JS_TAGS = { cell: 0xfffa, str: 0xfffb, bigint: 0xfffc } as const; diff --git a/packages/compiler/src/backend/llvm/jsval.ts b/packages/compiler/src/backend/llvm/jsval.ts new file mode 100644 index 000000000..8c955f6ee --- /dev/null +++ b/packages/compiler/src/backend/llvm/jsval.ts @@ -0,0 +1,575 @@ +/* The dynamic tier's jsval values (two-tier design stage S3; runtime + * scr_js.h / scr_js_value.c). + * + * A jsval is a 64-bit NaN-boxed word carried as an LLVM `ptr` (the bits + * travel through ptrtoint/inttoptr and are never dereferenced untagged), so + * the emitter's frames, locals and calls handle it like any refcounted + * pointer; its retain/release helpers skip immediates. Encoding (scr_js.h): + * + * below LIMIT an IEEE double, NaN canonical (0x7FF8000000000000) + * 0xFFF9 << 48 | n undefined 0, null 1, false 2, true 3 + * 0xFFFA << 48 | p a ScrDyn cell + * 0xFFFB << 48 | p a ScrStr + * 0xFFFC << 48 | p a ScrBigInt + * + * Every js.* builtin inlines its number case: an operand is a number when + * it is an immediate double or a NUM cell (values read from dyn objects + * stay cells), and results are canonicalized immediates. Everything else + * calls the runtime, which defers to the dyn operators. 32-bit targets + * have no room for a double in a pointer: there a jsval is a ScrDyn cell + * and every js.* builtin is a runtime call. */ +import { InternalCompilerError } from "../../errors.js"; +import { DYN, type IrExpr } from "../../ir/ir.js"; +import type { LibCallExpr, LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { f64Lit } from "./common.js"; +import { JS_BITS, JS_TAGS } from "./jsval-encoding.js"; +import { releaseSym } from "./shapes.js"; + +/** SCR_DYN_NUM and the ScrDyn layout facts the number fast path reads + * (64-bit targets only): kind at +8, v.num at +16. */ +const DYN_NUM = 2; + +type Host = LlvmEmitterContext; + +/** Whether this target represents jsvals as NaN-boxed words. */ +export function jsImmediates(host: { sizeType: "i32" | "i64" }): boolean { + return host.sizeType === "i64"; +} + +/** Whether an emitted value is a temp the current statement frames own + * (so a conversion may move it instead of retaining it). */ +function ownedTemp(host: Host, v: LlValue): boolean { + for (const frame of host.frames) + for (const entry of frame) if (entry.slot !== true && entry.name === v.name) return true; + return false; +} + +/** An emitted value with its ownership taken: moved out of its frame when + * it was a temp, retained otherwise. */ +function takeOwned(host: Host, v: LlValue): string { + if (ownedTemp(host, v)) { + host.moveTemp(v); + return v.name; + } + return host.retainValue(v.name, v.type); +} + +function bitsOf(host: Host, v: string): string { + const b = host.B.tmp(); + host.B.line(`${b} = ptrtoint ptr ${v} to i64`); + return b; +} + +function wordOf(host: Host, bits: string): string { + const v = host.B.tmp(); + host.B.line(`${v} = inttoptr i64 ${bits} to ptr`); + return v; +} + +/** A double as a jsval word: NaN canonicalized. */ +function boxDouble(host: Host, d: string): string { + const B = host.B; + const raw = B.tmp(); + const nan = B.tmp(); + const bits = B.tmp(); + B.line(`${raw} = bitcast double ${d} to i64`); + B.line(`${nan} = fcmp uno double ${d}, ${d}`); + B.line(`${bits} = select i1 ${nan}, i64 ${JS_BITS.canonicalNan}, i64 ${raw}`); + return wordOf(host, bits); +} + +/** An operand of a number fast path: its jsval word (computed on demand + * for a static number), and the double a fast path computes with. */ +interface Operand { + word: () => string; + /** Emits the number test: returns the double and branches to `slow` + * when the operand is not a number; a static number needs no test. */ + number: (slow: string) => string; +} + +/** A static value crossing into jsval: emitted, and its dyn converter + * requested exactly as the `dynFrom` it replaces requested it. Converters + * are numbered per module in request order, so skipping the request would + * renumber the converters static functions call (their code must not + * change by a byte). An unused converter is dead code LLVM drops. */ +function emitStaticSource(host: Host, value: IrExpr): LlValue { + const v = host.emitExpr(value); + host.dyn.toDynHelper(v.type); + return v; +} + +function operand(host: Host, e: IrExpr): Operand { + const B = host.B; + if (e.kind === "libCall" && e.fn === "js.fromF64") { + const d = emitStaticSource(host, e.args[0]!).name; + const word: string[] = []; + return { + word: () => { + if (word.length === 0) word.push(boxDouble(host, d)); + return word[0]!; + }, + number: () => d, + }; + } + const v = host.emitExpr(e).name; + return { + word: () => v, + number: (slow) => { + const bits = bitsOf(host, v); + const isDouble = B.tmp(); + B.line(`${isDouble} = icmp ult i64 ${bits}, ${JS_BITS.limit}`); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca double`); + const dbl = B.newLabel("js.dbl"); + const notDbl = B.newLabel("js.cell"); + const isNum = B.newLabel("js.num"); + const ok = B.newLabel("js.n"); + B.condBr(isDouble, dbl, notDbl); + B.startBlock(dbl); + const d = B.tmp(); + B.line(`${d} = bitcast i64 ${bits} to double`); + B.line(`store double ${d}, ptr ${slot}`); + B.br(ok); + B.startBlock(notDbl); + const tag = B.tmp(); + const cell = B.tmp(); + B.line(`${tag} = lshr i64 ${bits}, 48`); + B.line(`${cell} = icmp eq i64 ${tag}, ${JS_TAGS.cell}`); + const cellBlock = B.newLabel("js.cellk"); + B.condBr(cell, cellBlock, slow); + B.startBlock(cellBlock); + const addr = B.tmp(); + const p = B.tmp(); + const live = B.tmp(); + B.line(`${addr} = and i64 ${bits}, ${JS_BITS.payloadMask}`); + B.line(`${p} = inttoptr i64 ${addr} to ptr`); + B.line(`${live} = icmp ne ptr ${p}, null`); + const kindBlock = B.newLabel("js.kind"); + B.condBr(live, kindBlock, slow); + B.startBlock(kindBlock); + const kp = B.tmp(); + const kind = B.tmp(); + const num = B.tmp(); + B.line(`${kp} = getelementptr inbounds i8, ptr ${p}, i64 8 ; ->kind`); + B.line(`${kind} = load i32, ptr ${kp}`); + B.line(`${num} = icmp eq i32 ${kind}, ${DYN_NUM}`); + B.condBr(num, isNum, slow); + B.startBlock(isNum); + const np = B.tmp(); + const n = B.tmp(); + B.line(`${np} = getelementptr inbounds i8, ptr ${p}, i64 16 ; ->v.num`); + B.line(`${n} = load double, ptr ${np}`); + B.line(`store double ${n}, ptr ${slot}`); + B.br(ok); + B.startBlock(ok); + const out = B.tmp(); + B.line(`${out} = load double, ptr ${slot}`); + return out; + }, + }; +} + +/** The common shape of an inlined builtin: the fast path stores its result + * into a slot, the slow path calls the runtime (and checks for a throw), + * and the join reads the slot. */ +function withSlowPath( + host: Host, + ty: "ptr" | "double" | "i1", + fast: (slow: string) => string, + slow: () => string, + mayThrow: boolean, +): string { + const B = host.B; + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const slowLabel = B.newLabel("js.slow"); + const done = B.newLabel("js.done"); + const fastValue = fast(slowLabel); + B.line(`store ${ty} ${fastValue}, ptr ${slot}`); + B.br(done); + B.startBlock(slowLabel); + const slowValue = slow(); + B.line(`store ${ty} ${slowValue}, ptr ${slot}`); + if (mayThrow) host.emitPendingCheck(); + B.br(done); + B.startBlock(done); + const out = B.tmp(); + B.line(`${out} = load ${ty}, ptr ${slot}`); + return out; +} + +function call(host: Host, ret: string, fn: string, args: [string, string][]): string { + host.declare(`declare ${ret} @${fn}(${args.map(([t]) => t).join(", ")})`); + const r = host.B.tmp(); + host.B.line(`${r} = call ${ret} @${fn}(${args.map(([t, v]) => `${t} ${v}`).join(", ")})`); + return r; +} + +const ARITH_OPS: Readonly> = { + "-": "fsub", + "*": "fmul", + "/": "fdiv", + "%": "frem", +}; + +const COMPARE_OPS: Readonly> = { + "<": "olt", + "<=": "ole", + ">": "ogt", + ">=": "oge", +}; + +function literalOp(e: IrExpr): string | null { + return e.kind === "strLit" ? e.value : null; +} + +/** js.* libCalls. */ +export function emitJsLibCall(host: Host, e: LibCallExpr): LlValue { + if (!jsImmediates(host)) return emitBoxedJsLibCall(host, e); + const B = host.B; + const jsval = (name: string): LlValue => host.own({ name, type: e.type }); + const immediate = (name: string): LlValue => host.markImmortal({ name, type: e.type }); + switch (e.fn) { + case "js.fromDyn": { + const d = host.emitExpr(e.args[0]!); + const owned = takeOwned(host, d); + const bits = bitsOf(host, owned); + const tagged = B.tmp(); + B.line(`${tagged} = or i64 ${bits}, ${JS_BITS.cellTag}`); + return jsval(wordOf(host, tagged)); + } + case "js.toDyn": { + const v = host.emitExpr(e.args[0]!); + const moved = ownedTemp(host, v); + if (moved) host.moveTemp(v); + const out = withSlowPath( + host, + "ptr", + (slow) => { + const bits = bitsOf(host, v.name); + const tag = B.tmp(); + const cell = B.tmp(); + B.line(`${tag} = lshr i64 ${bits}, 48`); + B.line(`${cell} = icmp eq i64 ${tag}, ${JS_TAGS.cell}`); + const cellBlock = B.newLabel("js.todyn.cell"); + B.condBr(cell, cellBlock, slow); + B.startBlock(cellBlock); + const addr = B.tmp(); + B.line(`${addr} = and i64 ${bits}, ${JS_BITS.payloadMask}`); + const p = wordOf(host, addr); + return moved ? p : host.retainValue(p, DYN); + }, + () => + call(host, "ptr", moved ? "scr_js_to_dyn" : "scr_js_to_dyn_borrow", [["ptr", v.name]]), + false, + ); + return host.own({ name: out, type: DYN }); + } + case "js.fromF64": + return immediate(boxDouble(host, emitStaticSource(host, e.args[0]!).name)); + case "js.fromBool": { + const b = emitStaticSource(host, e.args[0]!).name; + const bits = B.tmp(); + B.line(`${bits} = select i1 ${b}, i64 ${JS_BITS.true}, i64 ${JS_BITS.false}`); + return immediate(wordOf(host, bits)); + } + case "js.fromStr": + case "js.fromBigInt": { + const s = emitStaticSource(host, e.args[0]!); + const owned = takeOwned(host, s); + const bits = bitsOf(host, owned); + const tagged = B.tmp(); + const tag = e.fn === "js.fromStr" ? JS_BITS.strTag : JS_BITS.bigintTag; + B.line(`${tagged} = or i64 ${bits}, ${tag}`); + return jsval(wordOf(host, tagged)); + } + case "js.toF64": { + const a = operand(host, e.args[0]!); + const out = withSlowPath( + host, + "double", + (slow) => a.number(slow), + () => { + // dynCheck's number check, on the value as a cell: the same + // TypeError for a non-number. + const boxed = call(host, "ptr", "scr_js_to_dyn_borrow", [["ptr", a.word()]]); + const helper = host.dyn.dynCheckHelper(e.type, undefined); + const d = B.tmp(); + B.line(`${d} = call double @${helper}(ptr ${boxed}, ptr null)`); + B.line(`call void ${releaseSym(host.shapeHost, DYN)}(ptr ${boxed})`); + return d; + }, + true, + ); + return { name: out, type: e.type }; + } + case "js.add": + case "js.arithmetic": { + const op = e.fn === "js.add" ? "+" : literalOp(e.args[2]!); + const inst = op === "+" ? "fadd" : op !== null ? ARITH_OPS[op] : undefined; + const a = operand(host, e.args[0]!); + const b = operand(host, e.args[1]!); + const opArg = e.fn === "js.arithmetic" ? host.emitExpr(e.args[2]!).name : null; + const runtime = (): string => + e.fn === "js.add" + ? call(host, "ptr", "scr_js_add", [ + ["ptr", a.word()], + ["ptr", b.word()], + ]) + : call(host, "ptr", "scr_js_arithmetic", [ + ["ptr", a.word()], + ["ptr", b.word()], + ["ptr", opArg!], + ]); + if (inst === undefined) { + // `**` (pow's JS edge cases) stays in the runtime. + const r = runtime(); + const out = jsval(r); + host.emitPendingCheck(); + return out; + } + const out = withSlowPath( + host, + "ptr", + (slow) => { + const x = a.number(slow); + const y = b.number(slow); + const r = B.tmp(); + B.line(`${r} = ${inst} double ${x}, ${y}`); + return boxDouble(host, r); + }, + runtime, + true, + ); + return jsval(out); + } + case "js.compare": { + const op = literalOp(e.args[2]!); + const pred = op !== null ? COMPARE_OPS[op] : undefined; + if (pred === undefined) + throw new InternalCompilerError(`llvm emitter bug: js.compare operator ${String(op)}`); + const a = operand(host, e.args[0]!); + const b = operand(host, e.args[1]!); + const opArg = host.emitExpr(e.args[2]!).name; + const out = withSlowPath( + host, + "i1", + (slow) => { + const x = a.number(slow); + const y = b.number(slow); + const r = B.tmp(); + B.line(`${r} = fcmp ${pred} double ${x}, ${y}`); + return r; + }, + () => + call(host, "zeroext i1", "scr_js_compare", [ + ["ptr", a.word()], + ["ptr", b.word()], + ["ptr", opArg], + ]), + true, + ); + return { name: out, type: e.type }; + } + case "js.toNumeric": + case "js.increment": { + const a = operand(host, e.args[0]!); + const step = e.fn === "js.increment" ? host.emitExpr(e.args[1]!).name : null; + const out = withSlowPath( + host, + "ptr", + (slow) => { + const x = a.number(slow); + if (step === null) return boxDouble(host, x); + const delta = B.tmp(); + const r = B.tmp(); + B.line(`${delta} = select i1 ${step}, double ${f64Lit(1)}, double ${f64Lit(-1)}`); + B.line(`${r} = fadd double ${x}, ${delta}`); + return boxDouble(host, r); + }, + () => + step === null + ? call(host, "ptr", "scr_js_to_numeric", [["ptr", a.word()]]) + : call(host, "ptr", "scr_js_increment", [ + ["ptr", a.word()], + ["i1 zeroext", step], + ]), + true, + ); + return jsval(out); + } + case "js.toNumberCoerce": { + const a = operand(host, e.args[0]!); + const out = withSlowPath( + host, + "double", + (slow) => a.number(slow), + () => call(host, "double", "scr_js_to_number", [["ptr", a.word()]]), + true, + ); + return { name: out, type: e.type }; + } + case "js.bitwise": { + const a = host.emitExpr(e.args[0]!).name; + const b = host.emitExpr(e.args[1]!).name; + const opArg = host.emitExpr(e.args[2]!).name; + const r = call(host, "ptr", "scr_js_bitwise", [ + ["ptr", a], + ["ptr", b], + ["ptr", opArg], + ]); + const out = jsval(r); + host.emitPendingCheck(); + return out; + } + default: + throw new InternalCompilerError(`llvm emitter bug: unknown jsval builtin ${e.fn}`); + } +} + +/** 32-bit targets: a jsval is a ScrDyn cell, so the conversions to and from + * dyn are moves and every operator is a runtime call. */ +function emitBoxedJsLibCall(host: Host, e: LibCallExpr): LlValue { + const B = host.B; + const args = (): LlValue[] => e.args.map((arg) => host.emitExpr(arg)); + const owned = (name: string): LlValue => host.own({ name, type: e.type }); + const throwing = (v: LlValue): LlValue => { + host.emitPendingCheck(); + return v; + }; + switch (e.fn) { + case "js.fromDyn": + case "js.toDyn": { + const v = host.emitExpr(e.args[0]!); + return owned(takeOwned(host, v)); + } + case "js.fromF64": + return owned( + call(host, "ptr", "scr_dyn_new_num", [["double", emitStaticSource(host, e.args[0]!).name]]), + ); + case "js.fromBool": + return owned( + call(host, "ptr", "scr_dyn_new_bool", [ + ["i1 zeroext", emitStaticSource(host, e.args[0]!).name], + ]), + ); + case "js.fromStr": + return owned( + call(host, "ptr", "scr_js_from_str", [["ptr", emitStaticSource(host, e.args[0]!).name]]), + ); + case "js.fromBigInt": + return owned( + call(host, "ptr", "scr_js_from_bigint", [["ptr", emitStaticSource(host, e.args[0]!).name]]), + ); + case "js.toF64": { + const v = host.emitExpr(e.args[0]!).name; + const helper = host.dyn.dynCheckHelper(e.type, undefined); + const d = B.tmp(); + B.line(`${d} = call double @${helper}(ptr ${v}, ptr null)`); + return throwing({ name: d, type: e.type }); + } + case "js.add": { + const [a, b] = args(); + return throwing( + owned( + call(host, "ptr", "scr_js_add", [ + ["ptr", a!.name], + ["ptr", b!.name], + ]), + ), + ); + } + case "js.arithmetic": + case "js.bitwise": { + const [a, b, op] = args(); + const fn = e.fn === "js.arithmetic" ? "scr_js_arithmetic" : "scr_js_bitwise"; + return throwing( + owned( + call(host, "ptr", fn, [ + ["ptr", a!.name], + ["ptr", b!.name], + ["ptr", op!.name], + ]), + ), + ); + } + case "js.compare": { + const [a, b, op] = args(); + const r = call(host, "zeroext i1", "scr_js_compare", [ + ["ptr", a!.name], + ["ptr", b!.name], + ["ptr", op!.name], + ]); + return throwing({ name: r, type: e.type }); + } + case "js.toNumeric": + return throwing(owned(call(host, "ptr", "scr_js_to_numeric", [["ptr", args()[0]!.name]]))); + case "js.increment": { + const [a, step] = args(); + return throwing( + owned( + call(host, "ptr", "scr_js_increment", [ + ["ptr", a!.name], + ["i1 zeroext", step!.name], + ]), + ), + ); + } + case "js.toNumberCoerce": + return throwing({ + name: call(host, "double", "scr_js_to_number", [["ptr", args()[0]!.name]]), + type: e.type, + }); + default: + throw new InternalCompilerError(`llvm emitter bug: unknown jsval builtin ${e.fn}`); + } +} + +/** ToBoolean of a jsval (a toBool over an operator result): doubles and + * the special words inline, cells, strings and BigInts in the runtime. */ +export function jsTruthy(host: Host, v: string): string { + if (!jsImmediates(host)) { + host.declare(`declare zeroext i1 @scr_dyn_truthy(ptr)`); + const r = host.B.tmp(); + host.B.line(`${r} = call zeroext i1 @scr_dyn_truthy(ptr ${v})`); + return r; + } + const B = host.B; + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca i1`); + const bits = bitsOf(host, v); + const isDouble = B.tmp(); + B.line(`${isDouble} = icmp ult i64 ${bits}, ${JS_BITS.limit}`); + const dbl = B.newLabel("js.truthy.d"); + const other = B.newLabel("js.truthy.o"); + const special = B.newLabel("js.truthy.s"); + const heap = B.newLabel("js.truthy.h"); + const done = B.newLabel("js.truthy.j"); + B.condBr(isDouble, dbl, other); + B.startBlock(dbl); + const d = B.tmp(); + const t = B.tmp(); + B.line(`${d} = bitcast i64 ${bits} to double`); + B.line(`${t} = fcmp one double ${d}, ${f64Lit(0)}`); + B.line(`store i1 ${t}, ptr ${slot}`); + B.br(done); + B.startBlock(other); + const tag = B.tmp(); + const isSpecial = B.tmp(); + B.line(`${tag} = lshr i64 ${bits}, 48`); + B.line(`${isSpecial} = icmp eq i64 ${tag}, 65529`); + B.condBr(isSpecial, special, heap); + B.startBlock(special); + const isTrue = B.tmp(); + B.line(`${isTrue} = icmp eq i64 ${bits}, ${JS_BITS.true}`); + B.line(`store i1 ${isTrue}, ptr ${slot}`); + B.br(done); + B.startBlock(heap); + const r = call(host, "zeroext i1", "scr_js_truthy", [["ptr", v]]); + B.line(`store i1 ${r}, ptr ${slot}`); + B.br(done); + B.startBlock(done); + const out = B.tmp(); + B.line(`${out} = load i1, ptr ${slot}`); + return out; +} diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 4a9f4017a..a07aeec44 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -10,6 +10,7 @@ import { InternalCompilerError } from "../../errors.js"; import { MAY_THROW_LIB_FNS } from "../../ir/builtin-effects.js"; import { LlvmUnsupportedError } from "./unsupported.js"; +import { emitJsLibCall } from "./jsval.js"; import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./expr-context.js"; import { ASSERT_VERDICT_LIB_FNS, @@ -594,6 +595,8 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "dyn": case "global": return host.emitDynamicLibCall(e); + case "js": + return emitJsLibCall(host, e); case "fs": case "fsp": case "fileHandle": diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index b16fe2608..96221942f 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -129,7 +129,7 @@ function fixture(): IrModule { ], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/local-union-storage.test.ts b/packages/compiler/src/backend/llvm/local-union-storage.test.ts index 9931b3a47..b8f44408d 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -82,7 +82,7 @@ function fixture(): IrModule { ], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", unions: [{ id: "optional", arms: [STRING, UNDEFINED_T] }], @@ -359,7 +359,7 @@ test("number bindings of optional reads and `??` chains stay in stack storage", }); const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", unions: [{ id: "maybe", arms: [F64, UNDEFINED_T] }], diff --git a/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts b/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts index 26f2a7c3c..58d3a33d6 100644 --- a/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts +++ b/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts @@ -94,7 +94,7 @@ function fixture( body: body(ref("map", map), ref("key", key), ref("other", map)), }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", unions: [{ id: "optional", arms: [value, UNDEFINED_T] }], diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts index 989527a32..5dc3589e4 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.test.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.test.ts @@ -62,7 +62,7 @@ function fixture(key: IrType, value: IrType, read: "key" | "value"): IrModule { body, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [], @@ -141,7 +141,7 @@ function lookupFixture(): IrModule { body: [{ kind: "return", value: has, loc }], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [], diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts index 6c696aeb5..3725db060 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts @@ -71,7 +71,7 @@ function fixture(value: IrType = record, key: IrType = F64): IrModule { ], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], @@ -401,7 +401,7 @@ test("a number-key write after a read stores into the found entry and appends th ], }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [], diff --git a/packages/compiler/src/backend/llvm/publish.test.ts b/packages/compiler/src/backend/llvm/publish.test.ts index c2d55804c..5a84caa96 100644 --- a/packages/compiler/src/backend/llvm/publish.test.ts +++ b/packages/compiler/src/backend/llvm/publish.test.ts @@ -7,7 +7,7 @@ const node = { kind: "object" as const, className: "Node" }; const kids = { kind: "array" as const, elem: node }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index 88875fce4..fbe8d7c64 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -147,6 +147,14 @@ function expressionPreservesEdges( // Class brand tests compare a capsule's key; they run no user code. e.fn === "dyn.typedRefKeyIs" || e.fn === "dyn.typedRefIs" || + // jsval conversions (ir/jsval.ts) answer as the dyn forms they + // replace: moving a value between dyn and jsval reads it, and a + // number, boolean or string crosses like its dynFrom box. + e.fn === "js.fromDyn" || + e.fn === "js.toDyn" || + e.fn === "js.fromF64" || + e.fn === "js.fromBool" || + e.fn === "js.fromStr" || isStableReceiverOperand(e, "") ); // An exact capsule's instance is one load (and a retain when owned). diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 2371147a9..f5a1c9c21 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -117,6 +117,20 @@ const INERT_LIB_CALLS = new Set([ "cp.spawnSyncOpts", ]); +/** jsval conversions (ir/jsval.ts): no user code, no stores. */ +const JS_CONVERSIONS = new Set([ + "js.fromDyn", + "js.toDyn", + "js.fromF64", + "js.fromBool", + "js.fromStr", + "js.fromBigInt", + "js.toF64", +]); + +/** jsval conversions of static primitives. */ +const JS_PRIMITIVE_SOURCES = new Set(["js.fromF64", "js.fromBool", "js.fromStr", "js.fromBigInt"]); + /** Array methods that never release an existing element or run user code. */ const PRESERVING_ARRAY_METHODS = new Set([ "length", @@ -631,6 +645,13 @@ export class ReferenceWrites { * accessors) or absent. */ private inertLibCall(e: IrExpr & { kind: "libCall" }): boolean { if (e.fn === "error.nodeThrow" || isStableReceiverOperand(e, "")) return true; + // jsval conversions run no user code: they move a value between its + // dyn and jsval forms, and js.toF64 is dynCheck's number check. A + // comparison is inert exactly when its dyn twin is: every operand a + // wrapped primitive. + if (JS_CONVERSIONS.has(e.fn)) return true; + if (e.fn === "js.compare") + return e.args.every((arg) => arg.type.kind !== "jsval" || this.jsPrimitive(arg)); if (e.fn === "error.newOptions") return e.args.every( (arg) => @@ -650,6 +671,17 @@ export class ReferenceWrites { ); } + /** A jsval operand made from a primitive: it has no methods to call + * back into. */ + private jsPrimitive(e: IrExpr): boolean { + if (e.kind !== "libCall") return false; + if (e.fn === "js.fromDyn") { + const value = e.args[0]!; + return value.kind === "dynFrom" && this.primitive(value.value.type); + } + return JS_PRIMITIVE_SOURCES.has(e.fn); + } + /** Numbers, booleans, strings and unit values (and unions of them). */ private primitive(t: IrType): boolean { switch (t.kind) { diff --git a/packages/compiler/src/backend/llvm/regions.test.ts b/packages/compiler/src/backend/llvm/regions.test.ts index 2310db6c6..51ceb21c8 100644 --- a/packages/compiler/src/backend/llvm/regions.test.ts +++ b/packages/compiler/src/backend/llvm/regions.test.ts @@ -23,7 +23,7 @@ const span: IrType = { kind: "record", shapeId: "span" }; * class that cannot reach any (Label). */ function module(body: IrExpr[] = [], lifetimeRegions = true): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", ...(lifetimeRegions ? { lifetimeRegions: true as const } : {}), diff --git a/packages/compiler/src/backend/llvm/regions.ts b/packages/compiler/src/backend/llvm/regions.ts index 41f67c0cb..e88d5c1f8 100644 --- a/packages/compiler/src/backend/llvm/regions.ts +++ b/packages/compiler/src/backend/llvm/regions.ts @@ -120,6 +120,7 @@ export class LifetimeRegions { // Closures capture anything, and dyn values box class instances. case "func": case "dyn": + case "jsval": return true; default: return false; diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts index 8c2c46541..10b63c568 100644 --- a/packages/compiler/src/backend/llvm/shapes.test.ts +++ b/packages/compiler/src/backend/llvm/shapes.test.ts @@ -32,7 +32,7 @@ function declarationHost( tracedShapes: new Set(), tracedUnions: new Set(), nullableUnions: new NullableUnions({ - irVersion: 16, + irVersion: 17, sourceFile: "test.ts", functions: [], entry: "main", @@ -243,7 +243,7 @@ describe("inline dyn release budget", () => { describe("emitted object releases", () => { test("a surviving headered record enqueues only when not already buffered", () => { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "cycle.ts", entry: "main", functions: [], @@ -265,7 +265,7 @@ describe("emitted object releases", () => { describe("live-object audit notes", () => { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "audit.ts", entry: "main", functions: [], diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 23565eff4..3a4b0d99d 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -14,6 +14,7 @@ import { RUNTIME_EVENT_TARGET_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, + DYN, STRING, } from "../../ir/ir.js"; import { @@ -38,6 +39,7 @@ import { stackUnionBox, type InlineUnionFields, } from "./inline-unions.js"; +import { JS_BITS, JS_TAGS } from "./jsval-encoding.js"; import { LlvmUnsupportedError } from "./unsupported.js"; /** What the tables need from the emitter: the extern-declaration ledger @@ -183,6 +185,7 @@ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release * table above; the split exists so call sites read type-directedly. * Families with a mirrored fast path call the inline helper instead. */ export function retainSym(host: ShapeHost, t: IrType): string { + if (t.kind === "jsval") return jsvalRcSym(host, "retain"); const nullable = host.nullableUnions.of(t); if (nullable) return nullableRetainSym(host, nullable.arm); const stem = runtimeRcStem(t); @@ -232,6 +235,7 @@ export function emitNullableRetainWrappers(host: ShapeHost): string[] { * points serve where one exists; records use their emitted helper. * Families with a mirrored fast path call the inline helper instead. */ export function releaseSym(host: ShapeHost, t: IrType): string { + if (t.kind === "jsval") return jsvalRcSym(host, "release"); const nullable = host.nullableUnions.of(t); if (nullable) return releaseSym(host, nullable.arm); const stem = runtimeRcStem(t); @@ -255,6 +259,67 @@ export function releaseSym(host: ShapeHost, t: IrType): string { } } +/** jsval retain/release (llvm/jsval.ts). On 32-bit targets a jsval is a + * dyn cell and uses dyn's entry points. Otherwise the inline helper skips + * immediates and forwards a cell to dyn's own entry point (requested here, + * so it is emitted with the other helpers) and a string or BigInt to the + * runtime; without inline RC every retain/release is a runtime call. */ +function jsvalRcSym(host: ShapeHost, op: RcOp): string { + if (host.sizeType === "i32") + return op === "retain" ? retainSym(host, DYN) : releaseSym(host, DYN); + if (host.rcHelpers === null || mirroredFamily(host, "js", op) === null) { + const sym = op === "retain" ? "@scr_js_retain" : "@scr_js_release"; + host.declare(op === "retain" ? `declare ptr ${sym}(ptr)` : `declare void ${sym}(ptr)`); + return sym; + } + if (op === "retain") retainSym(host, DYN); + else releaseSym(host, DYN); + return requestRcHelper(host, "js", op); +} + +/** The inline retain/release helpers of jsval (64-bit targets). A cell + * goes through dyn's own retain/release entry point (inline or runtime); + * a string or BigInt through the runtime; immediates do nothing. */ +function jsvalRcHelper(host: ShapeHost, op: RcOp, attrs: string): string[] { + const dynOp = op === "retain" ? retainSym(host, DYN) : releaseSym(host, DYN); + const runtime = op === "retain" ? "@scr_js_retain" : "@scr_js_release"; + // Always inlined (the helper is a tag dispatch): LLVM would otherwise + // weigh it together with the dyn helper it calls and keep both out of + // line. dyn's release tolerates NULL; its retain does not. + const inline = host.rcHelpersNoinline === true ? attrs : `alwaysinline ${attrs}`; + return [ + `define internal ${op === "retain" ? "ptr" : "void"} @sc_rc_${op}_js(ptr %o) ${inline} { ; jsval ${op}: cells, strings and BigInts`, + `entry:`, + ` %b = ptrtoint ptr %o to i64`, + ` %tag = lshr i64 %b, 48`, + ` %cell = icmp eq i64 %tag, ${JS_TAGS.cell}`, + ` br i1 %cell, label %c, label %other`, + `c:`, + ` %a = and i64 %b, ${JS_BITS.payloadMask}`, + ` %p = inttoptr i64 %a to ptr`, + ...(op === "retain" + ? [ + ` %live = icmp ne ptr %p, null`, + ` br i1 %live, label %rc, label %done`, + `rc:`, + ` %r = call ptr ${dynOp}(ptr %p)`, + ] + : [` call void ${dynOp}(ptr %p)`]), + ` br label %done`, + `other:`, + ` %h = sub i64 %tag, ${JS_TAGS.str}`, + ` %heap = icmp ult i64 %h, 2`, + ` br i1 %heap, label %slow, label %done`, + `slow:`, + op === "retain" ? ` %s = call ptr ${runtime}(ptr %o)` : ` call void ${runtime}(ptr %o)`, + ` br label %done`, + `done:`, + ` ${op === "retain" ? "ret ptr %o" : "ret void"}`, + `}`, + ``, + ]; +} + /* ── inline RC fast paths ─────────────────────────────────────────────── */ /** How a runtime family decides whether an object carries a ScrCycHdr: @@ -321,7 +386,17 @@ export function inlineRcFamily(host: ShapeHost, stem: string, op: RcOp): InlineR * because string-heavy programs have many release sites. */ export type InlineRcMode = "release" | "speed"; -const RELEASE_RC_OPS = new Set(["str:retain", "dyn:retain", "dyn:release"]); +const RELEASE_RC_OPS = new Set([ + "str:retain", + "dyn:retain", + "dyn:release", + "js:retain", + "js:release", +]); + +/** The jsval helpers' row: their bodies are custom (jsvalRcHelper), and + * they call the runtime themselves. */ +const JS_RC_ROW: InlineRcFamily = { cycle: "none", retainNull: true, release: null }; /** The emitter's inline RC option for a native optimization posture: none * at `dev`, the `release` subset by default. Sanitized builds compile the @@ -339,6 +414,8 @@ export function inlineRcOption( } function mirroredFamily(host: ShapeHost, family: string, op: RcOp): InlineRcFamily | null { + if (family === "js") + return host.rcMode !== "speed" && !RELEASE_RC_OPS.has(`js:${op}`) ? null : JS_RC_ROW; if (!Object.hasOwn(INLINE_RC_FAMILIES, family)) return null; if (host.rcMode !== "speed" && !RELEASE_RC_OPS.has(`${family}:${op}`)) return null; const row = INLINE_RC_FAMILIES[family]!; @@ -444,6 +521,10 @@ export function limitInlineDynReleases(parts: readonly string[]): string[] { } function rcHelperDecls(family: string, op: RcOp): string[] { + if (family === "js") + return [ + op === "retain" ? `declare ptr @scr_js_retain(ptr)` : `declare void @scr_js_release(ptr)`, + ]; const row = INLINE_RC_FAMILIES[family]!; if (op === "retain" || row.release === null) return []; return [ @@ -612,6 +693,10 @@ export function emitInlineRcHelpers(host: ShapeHost): string[] { const out: string[] = []; for (const key of rcHelperKeys(host).sort()) { const [family, op] = key.split(":") as [string, RcOp]; + if (family === "js") { + out.push(...jsvalRcHelper(host, op, rcHelperAttrs(host))); + continue; + } const row = INLINE_RC_FAMILIES[family]!; out.push( ...(op === "retain" ? retainHelper(host, family, row) : releaseHelper(host, family, row)), diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts index a413db333..a74878080 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.test.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -214,7 +214,7 @@ test("loop bindings are never reassigned into a fixed slot", () => { ], }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [{ id: "point", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/stack-unions.test.ts b/packages/compiler/src/backend/llvm/stack-unions.test.ts index 6baf328ed..221f4c903 100644 --- a/packages/compiler/src/backend/llvm/stack-unions.test.ts +++ b/packages/compiler/src/backend/llvm/stack-unions.test.ts @@ -73,7 +73,7 @@ function fixture(arm: IrType = record): IrModule { body: [ret(narrow(ref("value"), arm))], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/llvm/string-construction.test.ts b/packages/compiler/src/backend/llvm/string-construction.test.ts index cfc7782d5..1b8e2a45f 100644 --- a/packages/compiler/src/backend/llvm/string-construction.test.ts +++ b/packages/compiler/src/backend/llvm/string-construction.test.ts @@ -17,7 +17,7 @@ const concat = (left: IrExpr, right: IrExpr): IrExpr => ({ function module(value: IrExpr): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index bf140d2e6..f6a75bb62 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -73,7 +73,7 @@ function fn(name: string, names: string[], value: IrExpr): IrFunction { } function mod(...functions: IrFunction[]): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts index 086d35a9d..7a93b6a49 100644 --- a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts +++ b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts @@ -13,7 +13,7 @@ function emit(type: IrType, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64) function emitWith(disc: IrExpr, tests: (IrExpr | null)[], pointerBits: 32 | 64 = 64): string { const type = disc.kind === "varRef" ? disc.type : F64; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -151,7 +151,7 @@ test("long string labels select by their first word and confirm the rest inline" function emitLlvmModuleFor(labels: string[]): string { const value: IrExpr = { kind: "varRef", localId: "value", type: STRING, loc }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/backend/llvm/union-retype.test.ts b/packages/compiler/src/backend/llvm/union-retype.test.ts index eca6adcd6..7c175b671 100644 --- a/packages/compiler/src/backend/llvm/union-retype.test.ts +++ b/packages/compiler/src/backend/llvm/union-retype.test.ts @@ -89,7 +89,7 @@ function fixture(): IrModule { loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [{ id: "cell", fields: [{ name: "x", type: F64 }] }], diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index 07b127c37..0a950c2cc 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -28,7 +28,7 @@ function fn(name: string, body: IrStmt[], locals: IrLocal[] = [local]): IrFuncti return { name, body, locals, params: [], returnType: VOID, loc }; } function moduleWith(...functions: IrFunction[]): IrModule { - return { irVersion: 16, sourceFile: loc.file, entry: "caller", functions }; + return { irVersion: 17, sourceFile: loc.file, entry: "caller", functions }; } const assignment: IrStmt = { kind: "assign", localId: local.id, value, loc }; const expression: IrExpr = { kind: "assignExpr", localId: local.id, value, type: F64, loc }; diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 8f69cea57..76ea9c4b6 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -28,6 +28,9 @@ export interface NativeLinkFeatures { dynInvoke: boolean; dc: boolean; dynAsync: boolean; + /** Dynamic-tier jsval operations: the scr_js_* units. Absent (not false) + * for programs without them, so their link features stay unchanged. */ + dynTier?: boolean; events: boolean; emitter: boolean; symbol: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 5e981fe44..dd697f13e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -107,6 +107,10 @@ export interface CcOptions { * reactions, AsyncLocalStorage, the unhandledRejection/warning * process events. */ dynAsync?: boolean; + /** The program has dynamic-tier jsval operations (moduleUsesDynTier on + * the IR): compiles the scr_js_* units (two-tier design I3). Programs + * without them link no scr_js_ symbol. */ + dynTier?: boolean; /** The program constructs EventTarget/Event/CustomEvent values or * calls events.addAbortListener (the event.* libCalls), or uses the * native fetch AbortSignal built on the same engine: compiles @@ -265,6 +269,8 @@ export interface LibArchiveOptions { threadInstances?: boolean; /** IR-detected link gates (the compileC precedent, refusal-narrowed). */ dynInvoke?: boolean; + /** See CompileOptions.dynTier: the scr_js_* units. */ + dynTier?: boolean; regex?: boolean; assert?: boolean; inspect?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 549e9ebbf..547f231e8 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -502,6 +502,7 @@ export async function compileCInternal( : []), ...(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"))] diff --git a/packages/compiler/src/backend/native/library.ts b/packages/compiler/src/backend/native/library.ts index dffa4ef5b..23d081bfe 100644 --- a/packages/compiler/src/backend/native/library.ts +++ b/packages/compiler/src/backend/native/library.ts @@ -112,6 +112,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise ...(opts.assert && opts.bigint ? ["scr_bigint_assert.c"] : []), ...(opts.emitter ? ["scr_events_emitter.c", "scr_dyn_handle.c"] : []), ...(opts.dynInvoke ? ["scr_dyn_invoke.c"] : []), + ...(opts.dynTier === true ? ["scr_js_value.c"] : []), ...(opts.zlib ? ["scr_zlib.c"] : []), ...(opts.copying ? ["scr_copying.c"] : []), ]; diff --git a/packages/compiler/src/backend/publication.test.ts b/packages/compiler/src/backend/publication.test.ts index 38dd11833..149ea4bb9 100644 --- a/packages/compiler/src/backend/publication.test.ts +++ b/packages/compiler/src/backend/publication.test.ts @@ -54,7 +54,7 @@ test("published types close over fields, containers and whole hierarchies", () = cls("Other", [["n", F64]]), ]; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", classes, diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 039ecc209..e25aab44b 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -81,6 +81,7 @@ export interface RuntimePackManifest { export interface RuntimeFeatureSet extends NativeLinkFeatures { workers: boolean; eventTarget: boolean; + dynTier: boolean; nativeFetch: boolean; netEffective: boolean; httpEffective: boolean; @@ -267,6 +268,7 @@ export function effectiveRuntimeFeatures( ...features, workers: features.workers === true, eventTarget: features.eventTarget === true, + dynTier: features.dynTier === true, nativeFetch, netEffective, httpEffective, diff --git a/packages/compiler/src/backend/target-diagnostics.test.ts b/packages/compiler/src/backend/target-diagnostics.test.ts index bc16eaa31..e1b344f37 100644 --- a/packages/compiler/src/backend/target-diagnostics.test.ts +++ b/packages/compiler/src/backend/target-diagnostics.test.ts @@ -35,7 +35,7 @@ const fetch: IrExpr = { }; function module(direct: IrExpr[] = []): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "%main", records: [], diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index fbd7e4edd..b0af8a4c7 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -61,6 +61,7 @@ export interface EarlyExecutableNativeFeatures { dynInvoke: boolean; dc: boolean; dynAsync: boolean; + dynTier?: boolean; events: boolean; emitter: boolean; symbol: boolean; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d104eae39..7ce57727e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -111,6 +111,7 @@ import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; +import { promoteJsvals } from "../../ir/jsval.js"; import { analyzeTiers, dynamicStatementCount, @@ -6382,7 +6383,7 @@ export class Lowerer { this.diags.length > 0 ? null : { - irVersion: 16, + irVersion: 17, sourceFile: this.entry.fileName, functions, classes: artifacts.classes, @@ -6403,6 +6404,10 @@ export class Lowerer { this.dynamicFunctions = assignLegacyTiers(functions); if (this.dynamicFunctions.length > 0) this.dynamicBodies = functions.filter((fn) => fn.tier === "dynamic"); + // Dynamic-tier functions compute with jsval values (ir/jsval.ts). The + // rewrite keeps every dynamic operation dynamic (each dyn operator + // becomes its js twin), so coverage's dynamic statements are unchanged. + if (module && this.dynamicFunctions.length > 0) promoteJsvals(module); return { module, diagnostics: this.diags, diff --git a/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts b/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts index 7e7a97182..6f6d61649 100644 --- a/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts +++ b/packages/compiler/src/frontend/lowering/sanitize-class-types.test.ts @@ -21,7 +21,7 @@ test("fenced instance slots are erased throughout signatures, storage and nested }; const ref = (type: IrType): IrExpr => ({ kind: "varRef", localId: "x", type, loc }); const module: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", globals: [{ id: "g", name: "g", type: composite, mutable: false }], @@ -110,7 +110,7 @@ test("modules without optional tables and registered class types retain their st const loc = { file: "registered.ts", start: 0, end: 1 }; const type: IrType = { kind: "object", className: "Registered" }; const module: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 8157d8c04..f1a564062 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -633,6 +633,7 @@ export function formatIrType( case "bool": return append("boolean"); case "dyn": + case "jsval": return append("unknown"); case "caught": // What tsc calls the binding; the fence messages carry the real story. diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 56b787e18..e9ef0620f 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -533,6 +533,7 @@ async function compileExecutableNative( dynInvoke: features.dynInvoke, dc: features.dc, dynAsync: features.dynAsync, + ...(features.dynTier ? { dynTier: true } : {}), events: features.events, emitter: features.emitter, symbol: features.symbol, @@ -1340,6 +1341,7 @@ function libraryNativeFeatures(mod: IrModule, backend: "llvm"): EarlyLibraryNati copying: features.copying, textDecoderLegacy: features.legacyTextDecoder, dynInvoke: features.dynInvoke, + ...(features.dynTier ? { dynTier: true } : {}), ...(mod.lib?.identity !== undefined ? { buildId: mod.lib.identity.buildId } : {}), }; } @@ -1433,6 +1435,7 @@ async function compileLibraryNative( copying: features.copying, textDecoderLegacy: features.textDecoderLegacy, dynInvoke: features.dynInvoke, + ...(features.dynTier === true ? { dynTier: true } : {}), }; if (packTarget !== null) await compilePackedLibrary(archiveOptions, packTarget); else await compileExternalCLibrary(archiveOptions); diff --git a/packages/compiler/src/ir/analysis.ts b/packages/compiler/src/ir/analysis.ts index 88f6f394f..5c57d06f1 100644 --- a/packages/compiler/src/ir/analysis.ts +++ b/packages/compiler/src/ir/analysis.ts @@ -149,6 +149,7 @@ export function dynDesc( case "undefinedT": return "undefined"; case "dyn": + case "jsval": return "unknown"; case "promise": return "Promise"; diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1bb9cbd14..325bf1175 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -377,6 +377,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.propertyKey", "dyn.toNumeric", "dyn.increment", + // The dynamic-tier operators run the same coercions (scr_js_value.c), and + // js.toF64 throws dynCheck's TypeError for a non-number. + "js.toF64", + "js.add", + "js.arithmetic", + "js.bitwise", + "js.compare", + "js.toNumeric", + "js.increment", + "js.toNumberCoerce", // Error messages use the same coercion protocol before installing cause. "error.newOptions", "error.ctorOptions", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ca2749591..76fe134f4 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -22,6 +22,7 @@ import { HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, + JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, @@ -3054,6 +3055,29 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.compare": { argTypes: [DYN, DYN, STRING], result: BOOL }, "dyn.bitwise": { argTypes: [DYN, DYN, STRING], result: DYN }, "dyn.proxyNew": { argTypes: [DYN, DYN], result: DYN }, + /* Dynamic-tier values (jsval; two-tier design stage S3). The jsval pass + * (ir/jsval.ts) introduces these in dynamic-tier functions only; the + * runtime is scr_js_value.c. Arguments are borrowed and results owned, + * like every libCall. Boundary conversions: js.fromDyn (a cell keeps its + * identity), js.toDyn (an immediate is boxed), js.fromF64/fromBool/ + * fromStr/fromBigInt (the design's toJs: immediates, and strings and + * BigInts under a tag without a copy) and js.toF64 (fromJs to number: dynCheck's + * number check and TypeError). The operators are the dyn.* operators + * over jsvals, with identical JS semantics. */ + "js.fromDyn": { argTypes: [DYN], result: JSVAL }, + "js.toDyn": { argTypes: [JSVAL], result: DYN }, + "js.fromF64": { argTypes: [F64], result: JSVAL }, + "js.fromBool": { argTypes: [BOOL], result: JSVAL }, + "js.fromStr": { argTypes: [STRING], result: JSVAL }, + "js.fromBigInt": { argTypes: [BIGINT_T], result: JSVAL }, + "js.toF64": { argTypes: [JSVAL], result: F64 }, + "js.add": { argTypes: [JSVAL, JSVAL], result: JSVAL }, + "js.arithmetic": { argTypes: [JSVAL, JSVAL, STRING], result: JSVAL }, + "js.bitwise": { argTypes: [JSVAL, JSVAL, STRING], result: JSVAL }, + "js.compare": { argTypes: [JSVAL, JSVAL, STRING], result: BOOL }, + "js.toNumeric": { argTypes: [JSVAL], result: JSVAL }, + "js.increment": { argTypes: [JSVAL, BOOL], result: JSVAL }, + "js.toNumberCoerce": { argTypes: [JSVAL], result: F64 }, // Always throws; the result is the READ's declared type (a typed dummy // the unwind abandons) — the libCall case skips the result check. /** A read of a `declare`d const NOTHING defines (the bundler-define diff --git a/packages/compiler/src/ir/callback-specialization.test.ts b/packages/compiler/src/ir/callback-specialization.test.ts index ec0a8d1a3..7a8d0fd64 100644 --- a/packages/compiler/src/ir/callback-specialization.test.ts +++ b/packages/compiler/src/ir/callback-specialization.test.ts @@ -63,7 +63,7 @@ function fixture(targets: string[]): IrModule { loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [...new Set(targets)].map(predicate).concat([consumer, entry]), diff --git a/packages/compiler/src/ir/constant-globals.test.ts b/packages/compiler/src/ir/constant-globals.test.ts index 18ee0f17b..15b88c065 100644 --- a/packages/compiler/src/ir/constant-globals.test.ts +++ b/packages/compiler/src/ir/constant-globals.test.ts @@ -30,7 +30,7 @@ const global = (id: string, extra: object = {}) => ({ test("immutable globals stored once with a literal read as that literal", () => { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "constant-globals.ts", entry: "%init", functions: [ diff --git a/packages/compiler/src/ir/constant-tables.test.ts b/packages/compiler/src/ir/constant-tables.test.ts index 0f9673791..13e8e2970 100644 --- a/packages/compiler/src/ir/constant-tables.test.ts +++ b/packages/compiler/src/ir/constant-tables.test.ts @@ -20,7 +20,7 @@ const expr = (value: IrExpr): IrStmt => ({ kind: "exprStmt", expr: value, loc }) function fixture(): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", globals: [{ id, name: "table", type, mutable: false }], diff --git a/packages/compiler/src/ir/field-values.test.ts b/packages/compiler/src/ir/field-values.test.ts index 1b66d1a30..d2b1d3e9a 100644 --- a/packages/compiler/src/ir/field-values.test.ts +++ b/packages/compiler/src/ir/field-values.test.ts @@ -74,7 +74,7 @@ const grid: IrClassDef = { methods: [], } as unknown as IrClassDef; const moduleOf = (functions: IrFunction[]): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: "field-values.ts", entry: "%init", classes: [grid], diff --git a/packages/compiler/src/ir/final-fields.test.ts b/packages/compiler/src/ir/final-fields.test.ts index 7f9fd06ca..fe6f0cd9b 100644 --- a/packages/compiler/src/ir/final-fields.test.ts +++ b/packages/compiler/src/ir/final-fields.test.ts @@ -39,7 +39,7 @@ const cls = (name: string, fields: string[], base?: string): IrClassDef => ({ fields: fields.map((field) => ({ name: field, type: STRING })), }); const mod = (classes: IrClassDef[], functions: IrFunction[]): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: "final-fields.ts", entry: "%main", classes, diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 0b1f9fb49..f0b5048ff 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -230,7 +230,7 @@ const entry = (body: IrStmt[] = []): IrFunction => ({ loc, }); const module = (body: IrStmt[] = []): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "%main", functions: [target, entry(body)], diff --git a/packages/compiler/src/ir/init-only-globals.test.ts b/packages/compiler/src/ir/init-only-globals.test.ts index e7f5c7dba..9fec0cd0f 100644 --- a/packages/compiler/src/ir/init-only-globals.test.ts +++ b/packages/compiler/src/ir/init-only-globals.test.ts @@ -58,7 +58,7 @@ const guarded = (body: IrStmt[]): IrStmt[] => [ ...body, ]; const moduleOf = (globals: IrGlobal[], functions: IrFunction[]): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: "init-only-globals.ts", entry: "%init", globals: [guard, ...globals], diff --git a/packages/compiler/src/ir/initializer-bindings.test.ts b/packages/compiler/src/ir/initializer-bindings.test.ts index b76f7a1d9..bed4aeefe 100644 --- a/packages/compiler/src/ir/initializer-bindings.test.ts +++ b/packages/compiler/src/ir/initializer-bindings.test.ts @@ -50,7 +50,7 @@ function fixture(): IrModule { ], }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: fn.name, functions: [fn], diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index afe500b60..446b3512d 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -105,7 +105,7 @@ const newOf = (className: string, args: IrExpr[]): IrExpr => ({ type: obj(className), }); const mod = (classes: IrClassDef[], functions: IrFunction[]): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: "test.ts", entry: "%main", classes, diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index d7d06db7d..534c059b4 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -311,7 +311,7 @@ export class Int32Slots { } } -const DYN_KINDS = new Set(["dyn", "caught"]); +const DYN_KINDS = new Set(["dyn", "jsval", "caught"]); /** Expressions that hand their operands (or produce results) to runtime * helpers, reflection, promises or other code the IR does not show. */ const BOUNDARY_KINDS = new Set([ @@ -402,6 +402,19 @@ const DYN_READ_ONLY = new Set([ "fs.utimesSync", "insp.dyn", "insp.dynS", + // The dynamic-tier operators run their dyn twins (scr_js_value.c); the + // conversions move a value between dyn and jsval, and js.toF64 is + // dynCheck's number check. + "js.add", + "js.arithmetic", + "js.bitwise", + "js.compare", + "js.fromDyn", + "js.increment", + "js.toDyn", + "js.toF64", + "js.toNumberCoerce", + "js.toNumeric", "json.stringifyReplacer", "json.stringifyValue", "num.toStringRadix", diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 9a4a12189..2d442285a 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -337,6 +337,17 @@ export type IrType = * ' first" hint — or, in JavaScript sources, met with per-site * checked lowerings (SEMANTICS.md 115-117). */ | { kind: "dyn" } + /** A dynamic-tier value (two-tier design §5.1, stage S3): one 64-bit + * NaN-boxed word. Numbers, booleans, undefined and null are immediates + * (no allocation); strings and BigInts are the static tier's own objects + * under a pointer tag; every other value is a `dyn` cell under the cell + * tag. Only dynamic-tier functions operate on it (ir/tiers.ts, I1), and + * only through the `js.*` builtins, locals and moves: the jsval pass + * (ir/jsval.ts) introduces it after lowering, never the frontend. Static + * functions see it only as the operand or result of a boundary + * conversion. Refcounted: retain/release skip immediates. 32-bit targets + * represent it as a boxed `dyn` cell. */ + | { kind: "jsval" } /** A catch binding or its closure capture (never parameters, returns, * fields, arms or elements). The one global form is a module's recorded * evaluation error, written only from that module's catch binding and @@ -437,6 +448,7 @@ const POINTER_KIND_LIST = [ "record", "union", "dyn", + "jsval", "caught", "promise", "generator", @@ -490,6 +502,7 @@ export const RUNTIME_RC_STEMS: Record = { record: "", union: "scr_union", dyn: "scr_dyn", + jsval: "scr_js", caught: "scr_caught", promise: "scr_promise", generator: "scr_gen", @@ -595,6 +608,7 @@ export const CHILDWRITER_T: IrType = { kind: "childWriter" }; export const PROCSTREAM_T: IrType = { kind: "procStream" }; export const VOID: IrType = { kind: "void" }; export const DYN: IrType = { kind: "dyn" }; +export const JSVAL: IrType = { kind: "jsval" }; export const CAUGHT: IrType = { kind: "caught" }; export const UNDEFINED_T: IrType = { kind: "undefinedT" }; export const NULL_T: IrType = { kind: "nullT" }; @@ -818,6 +832,7 @@ export function typeKey(t: IrType): string { case "searchParams": case "symbol": case "dyn": + case "jsval": case "caught": case "void": return t.kind; @@ -918,7 +933,7 @@ export function isRefCounted(t: IrType): boolean { export interface IrModule { /** Bumped on any breaking IR change; serialize.ts refuses mismatches. */ - irVersion: 16; + irVersion: 17; sourceFile: string; functions: IrFunction[]; /** Class shapes. Constructors and methods are ordinary module functions @@ -3847,6 +3862,7 @@ function isJsonSafeAt( // diagnostic. case "symbol": case "caught": + case "jsval": case "promise": case "generator": case "moduleNs": diff --git a/packages/compiler/src/ir/jsval.test.ts b/packages/compiler/src/ir/jsval.test.ts new file mode 100644 index 000000000..89c7327bc --- /dev/null +++ b/packages/compiler/src/ir/jsval.test.ts @@ -0,0 +1,279 @@ +import { expect, test } from "vitest"; +import { + BOOL, + DYN, + F64, + JSVAL, + STRING, + VOID, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, +} from "./ir.js"; +import { promoteJsvals } from "./jsval.js"; +import { assignLegacyTiers } from "./tiers.js"; +import { validateModule } from "./validate.js"; + +const loc = { file: "jsval.ts", start: 0, end: 0 }; +const ref = (id: string, type = DYN): IrExpr => ({ kind: "varRef", localId: id, type, loc }); +const num = (value: number): IrExpr => ({ + kind: "dynFrom", + value: { kind: "numLit", value, type: F64, loc }, + type: DYN, + loc, +}); +const op = (fn: "dyn.add" | "dyn.arithmetic", l: IrExpr, r: IrExpr, symbol?: string): IrExpr => ({ + kind: "libCall", + fn, + args: + symbol === undefined ? [l, r] : [l, r, { kind: "strLit", value: symbol, type: STRING, loc }], + type: DYN, + loc, +}); +const keyGet = (from: IrExpr, key: string): IrExpr => ({ + kind: "dynKeyGet", + key: { kind: "strLit", value: key, type: STRING, loc }, + value: from, + type: DYN, + loc, +}); + +function fn(name: string, body: IrStmt[], locals: string[] = [], returnType = DYN): IrFunction { + return { + name, + params: [{ localId: "o", name: "o", type: DYN }], + returnType, + locals: [ + { id: "o", name: "o", type: DYN, mutable: false }, + ...locals.map((id) => ({ id, name: id, type: DYN, mutable: true })), + ], + body, + loc, + }; +} + +function moduleOf(...functions: IrFunction[]): IrModule { + const main: IrFunction = { + name: "%main", + params: [], + returnType: VOID, + locals: [], + body: [], + loc, + }; + const mod: IrModule = { + irVersion: 17, + sourceFile: "jsval.ts", + functions: [main, ...functions], + entry: "%main", + }; + assignLegacyTiers(mod.functions); + return mod; +} + +/** The js.* calls of a body, in source order. */ +function jsCalls(body: IrStmt[]): string[] { + const out: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(visit); + else if (value !== null && typeof value === "object") { + const node = value as { kind?: string; fn?: string }; + if (node.kind === "libCall" && node.fn?.startsWith("js.")) out.push(node.fn); + Object.values(value).forEach(visit); + } + }; + visit(body); + return out; +} + +test("operator chains compute with jsvals and box only where a dyn value is needed", () => { + // return o.x * o.y + 1 + const chain = fn("chain", [ + { + kind: "return", + value: op( + "dyn.add", + op("dyn.arithmetic", keyGet(ref("o"), "x"), keyGet(ref("o"), "y"), "*"), + num(1), + ), + loc, + }, + ]); + const mod = moduleOf(chain); + expect(chain.tier).toBe("dynamic"); + // A static caller keeps the dyn ABI: one box at the end of the chain, + // none in between; the static 1 is an immediate and the reads keep their + // cells. + const caller = fn("caller", [ + { + kind: "return", + value: { kind: "call", callee: "chain", args: [ref("o")], type: DYN, loc }, + loc, + }, + ]); + mod.functions.push(caller); + expect(caller.tier).toBeUndefined(); + expect(promoteJsvals(mod)).toBe(1); + expect(chain.returnType).toEqual(DYN); + expect(jsCalls(chain.body)).toEqual([ + "js.toDyn", + "js.add", + "js.arithmetic", + "js.fromDyn", + "js.fromDyn", + "js.fromF64", + ]); + expect(jsCalls(caller.body)).toEqual([]); + expect(validateModule(mod)).toEqual([]); +}); + +test("a dynamic function called only by dynamic functions returns a jsval", () => { + const twice = fn("twice", [ + { kind: "return", value: op("dyn.arithmetic", keyGet(ref("o"), "n"), num(2), "*"), loc }, + ]); + // return twice(o) + twice(o): no box between the calls and the add. + const sum = fn("sum", [ + { + kind: "return", + value: op( + "dyn.add", + { kind: "call", callee: "twice", args: [ref("o")], type: DYN, loc }, + { kind: "call", callee: "twice", args: [ref("o")], type: DYN, loc }, + ), + loc, + }, + ]); + const user = fn("user", [ + { + kind: "exprStmt", + expr: keyGet({ kind: "call", callee: "sum", args: [ref("o")], type: DYN, loc }, "x"), + loc, + }, + { kind: "return", value: ref("o"), loc }, + ]); + const mod = moduleOf(twice, sum, user); + promoteJsvals(mod); + expect(twice.returnType).toEqual(JSVAL); + expect(sum.returnType).toEqual(JSVAL); + expect(jsCalls(twice.body)).toEqual(["js.arithmetic", "js.fromDyn", "js.fromF64"]); + expect(jsCalls(sum.body)).toEqual(["js.add"]); + // A dyn consumer boxes the jsval result once. + expect(jsCalls(user.body)).toEqual(["js.toDyn", "js.fromDyn"]); + expect(validateModule(mod)).toEqual([]); +}); + +test("a number check of an operator result reads the jsval, and comparisons take jsvals", () => { + const checked = fn( + "checked", + [ + { + kind: "if", + cond: { + kind: "libCall", + fn: "dyn.compare", + args: [keyGet(ref("o"), "a"), num(3), { kind: "strLit", value: "<", type: STRING, loc }], + type: BOOL, + loc, + }, + then: [{ kind: "return", value: { kind: "numLit", value: 0, type: F64, loc }, loc }], + else_: null, + loc, + }, + { + kind: "return", + value: { + kind: "dynCheck", + value: op("dyn.arithmetic", keyGet(ref("o"), "a"), num(2), "%"), + type: F64, + loc, + }, + loc, + }, + ], + [], + F64, + ); + const mod = moduleOf(checked); + promoteJsvals(mod); + expect(jsCalls(checked.body)).toEqual([ + "js.compare", + "js.fromDyn", + "js.fromF64", + "js.toF64", + "js.arithmetic", + "js.fromDyn", + "js.fromF64", + ]); + expect(validateModule(mod)).toEqual([]); +}); + +test("a dyn local written by operators in a loop becomes a jsval local", () => { + // acc = o.start; for (...) acc = acc + o.step; return acc + const loop = fn( + "loop", + [ + { kind: "varDecl", localId: "acc", init: keyGet(ref("o"), "start"), loc }, + { + kind: "while", + cond: { kind: "boolLit", value: false, type: BOOL, loc }, + body: [ + { + kind: "assign", + localId: "acc", + value: op("dyn.add", ref("acc"), keyGet(ref("o"), "step")), + loc, + }, + ], + loc, + }, + { kind: "return", value: ref("acc"), loc }, + ], + ["acc"], + ); + const mod = moduleOf(loop); + promoteJsvals(mod); + expect(loop.locals.find((l) => l.id === "acc")!.type).toEqual(JSVAL); + expect(loop.returnType).toEqual(JSVAL); + expect(jsCalls(loop.body)).toEqual(["js.fromDyn", "js.add", "js.fromDyn"]); + expect(validateModule(mod)).toEqual([]); +}); + +test("a dyn local mostly read by dyn consumers stays dyn", () => { + // t = o.a + 1; for (...) use(t) + const reader = fn( + "reader", + [ + { kind: "varDecl", localId: "t", init: op("dyn.add", keyGet(ref("o"), "a"), num(1)), loc }, + { + kind: "while", + cond: { kind: "boolLit", value: false, type: BOOL, loc }, + body: [{ kind: "exprStmt", expr: keyGet(ref("t"), "x"), loc }], + loc, + }, + { kind: "return", value: ref("t"), loc }, + ], + ["t"], + ); + const mod = moduleOf(reader); + promoteJsvals(mod); + expect(reader.locals.find((l) => l.id === "t")!.type).toEqual(DYN); + expect(validateModule(mod)).toEqual([]); +}); + +test("static functions keep their dyn values; async functions keep their dyn result", () => { + const plain = fn("plain", [{ kind: "return", value: ref("o"), loc }]); + const asyncChain = fn("asyncChain", [ + { kind: "return", value: op("dyn.add", keyGet(ref("o"), "x"), num(1)), loc }, + ]); + asyncChain.async = true; + const mod = moduleOf(plain, asyncChain); + expect(plain.tier).toBeUndefined(); + expect(asyncChain.tier).toBe("dynamic"); + const before = JSON.stringify(plain); + expect(promoteJsvals(mod)).toBe(1); + expect(JSON.stringify(plain)).toBe(before); + expect(asyncChain.returnType).toEqual(DYN); + expect(jsCalls(asyncChain.body)).toEqual(["js.toDyn", "js.add", "js.fromDyn", "js.fromF64"]); + expect(validateModule(mod)).toEqual([]); +}); diff --git a/packages/compiler/src/ir/jsval.ts b/packages/compiler/src/ir/jsval.ts new file mode 100644 index 000000000..1939531f1 --- /dev/null +++ b/packages/compiler/src/ir/jsval.ts @@ -0,0 +1,384 @@ +/* The jsval pass (two-tier design stage S3): dynamic-tier functions compute + * with jsval values instead of dyn cells. + * + * Lowering builds every dynamic operation over `dyn`, the checked-dynamic + * cell (ScrDyn), where a number is a heap node: each dyn operator result + * allocates one. This pass rewrites the operator chains of dynamic-tier + * functions (ir/tiers.ts) to the `js.*` builtins over `jsval`, the + * NaN-boxed word whose numbers, booleans, undefined and null are + * immediates: + * + * - every dyn operator (`dyn.add`, `dyn.arithmetic`, `dyn.bitwise`, + * `dyn.toNumeric`, `dyn.increment`) becomes its `js.*` twin, whose + * operands are rewritten as jsvals too, so a chain `a * b + c` allocates + * nothing for its intermediate results; + * - the operator consumers `dyn.compare` and `dyn.toNumberCoerce` take + * jsval operands, a number check `dynCheck` of an operator result + * becomes `js.toF64`, and a truthiness test of one tests the jsval; + * - a static number or boolean operand (`dynFrom`) becomes an + * immediate (`js.fromF64`), a static string or BigInt the same object + * under a tag (`js.fromStr`, `js.fromBigInt`: no cell), and any other dyn + * operand a cell under the cell tag (`js.fromDyn`: no copy, identity + * kept); + * - an operator result that flows anywhere else (a property store, a call + * argument, a dyn local) is boxed back into a cell (`js.toDyn`), exactly + * the allocation the dyn operator made before; + * - a dynamic function whose only references are direct calls from + * dynamic functions returns a jsval, so a number it returns is not boxed + * (static callers, function values, exports and class members keep the + * dyn ABI); + * - a dyn local becomes a jsval local when the boxing it saves on writes + * of operator results outweighs the boxing its reads by dyn consumers add + * (loop depth weights both). + * + * The rewrite changes representation only: every js builtin implements its + * dyn twin's JS semantics, including ToPrimitive order and error messages + * (scr_js_value.c delegates every non-number case to the dyn operator). + * Static functions are never touched (invariant I1). Parameters, captures + * and containers still hold cells until the shaped object model and the + * dynamic call ABI (stages S4 and S5) replace them. + * + * Async functions and generators are rewritten like other functions (their + * suspensions keep jsvals like any pointer) but keep the dyn result ABI. */ +import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; +import { DYN, JSVAL, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { + everyExprChild, + everyStmtChild, + everyStmtList, + mapExprChildren, + mapStmtChildren, +} from "./traverse.js"; + +/** dyn operators whose result is a fresh dynamic value, with their jsval + * twins. Every argument typed dyn is an operand. */ +const PRODUCERS: Readonly> = { + "dyn.add": "js.add", + "dyn.arithmetic": "js.arithmetic", + "dyn.bitwise": "js.bitwise", + "dyn.toNumeric": "js.toNumeric", + "dyn.increment": "js.increment", +}; + +/** dyn operators with a static result, with their jsval twins. */ +const CONSUMERS: Readonly> = { + "dyn.compare": "js.compare", + "dyn.toNumberCoerce": "js.toNumberCoerce", +}; + +function isProducerCall(e: IrExpr): boolean { + return e.kind === "libCall" && Object.hasOwn(PRODUCERS, e.fn); +} + +function isConsumerCall(e: IrExpr): boolean { + return e.kind === "libCall" && Object.hasOwn(CONSUMERS, e.fn); +} + +/** The jsval conversion of a static value that crosses without a cell: + * numbers and booleans as immediates, strings and BigInts as the static + * tier's own objects under a tag. */ +const STATIC_SOURCES: Readonly> = { + f64: "js.fromF64", + bool: "js.fromBool", + string: "js.fromStr", + bigint: "js.fromBigInt", +}; + +function isImmediateSource(e: IrExpr): boolean { + return ( + e.kind === "dynFrom" && + e.liveRef === undefined && + e.fnName === undefined && + Object.hasOwn(STATIC_SOURCES, e.value.type.kind) + ); +} + +/** Whether a function's operators are rewritten: every dynamic-tier + * function. Async functions and generators suspend through LLVM coroutines + * or fibers, which keep jsval temporaries and locals like any pointer. */ +export function jsvalEligible(fn: IrFunction): boolean { + return fn.tier === "dynamic"; +} + +/** Rewrite every eligible dynamic-tier function of a module in place. + * Returns the number of functions that now use jsval. */ +export function promoteJsvals(mod: IrModule): number { + const results = jsvalResults(mod); + let count = 0; + for (const fn of mod.functions) if (jsvalEligible(fn) && promoteFunction(fn, results)) count++; + for (const fn of mod.functions) if (results.has(fn.name)) fn.returnType = JSVAL; + return count; +} + +/** Dynamic-tier functions whose dyn result becomes a jsval result, so a + * number they return is not boxed: eligible functions whose every + * reference is a direct call from a dynamic-tier function (static callers + * keep their exact code, I1). Function values, library exports, the entry + * and class members (`%.`, which vtables and the runtime + * reach by name) keep the dyn ABI. */ +function jsvalResults(mod: IrModule): Set { + const classPrefixes = (mod.classes ?? []).map((cls) => `%${cls.name}.`); + const results = new Set(); + for (const fn of mod.functions) { + // An async function's callers receive a promise and a generator's a + // generator object: only synchronous functions return their result. + if (!jsvalEligible(fn) || fn.async !== undefined || fn.generator !== undefined) continue; + if (fn.returnType.kind !== "dyn") continue; + if (fn.name.includes(".") && !fn.name.startsWith("%dyn.")) continue; + if (classPrefixes.some((prefix) => fn.name.startsWith(prefix))) continue; + results.add(fn.name); + } + if (results.size === 0) return results; + for (const name of moduleFunctionReferences(mod)) results.delete(name); + for (const fn of mod.functions) { + const dynamic = fn.tier === "dynamic"; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "call" && !dynamic) results.delete(e.callee); + const referenced = exprFunctionReference(e); + if (referenced !== undefined) results.delete(referenced); + if (e.kind === "dynFrom" && e.fnName !== undefined) results.delete(e.fnName); + return true; + }, + stmt: () => true, + }); + } + return results; +} + +/* ── local promotion ─────────────────────────────────────────────────── */ + +const LOOP_WEIGHT = 8; +const MAX_WEIGHT = 8 ** 6; + +interface LocalCost { + /** Boxing a jsval local saves: writes of operator results (and + * immediates), weighted by loop depth. */ + saved: number; + /** Boxing it adds: reads by dyn consumers, weighted the same way. */ + added: number; +} + +/** The dyn locals of a function that become jsval locals. `producer` + * tells which dyn expressions the rewrite computes as jsvals. */ +function promotableLocals( + fn: IrFunction, + producer: (e: IrExpr) => boolean, + jsResult: boolean, +): Set { + const candidates = new Map(); + for (const local of fn.locals) { + if (local.type.kind === "dyn" && local.boxed === undefined && local.tdz === undefined) + candidates.set(local.id, { saved: 0, added: 0 }); + } + for (const p of fn.params) candidates.delete(p.localId); + for (const c of fn.captures ?? []) candidates.delete(c.localId); + for (const c of fn.classCaptures ?? []) candidates.delete(c.localId); + if (candidates.size === 0) return new Set(); + const blocked = new Set(); + const write = (id: string, value: IrExpr | null, weight: number): void => { + const cost = candidates.get(id); + if (cost === undefined) return; + if (value === null) { + // A declared-only dyn binding is an uninitialized trap (ir/build.ts). + blocked.add(id); + return; + } + if (producer(value) || isImmediateSource(value)) cost.saved += weight; + }; + /* `js`: the expression is an operand the rewrite turns into a jsval. */ + const expr = (e: IrExpr, js: boolean, weight: number): void => { + switch (e.kind) { + case "varRef": { + const cost = candidates.get(e.localId); + if (cost !== undefined && !js) cost.added += weight; + return; + } + case "assignExpr": + write(e.localId, e.value, weight); + expr(e.value, false, weight); + // The assignment's own value is read where it stands. + if (!js && candidates.has(e.localId)) candidates.get(e.localId)!.added += weight; + return; + case "libCall": + if (isProducerCall(e) || isConsumerCall(e)) { + for (const arg of e.args) expr(arg, arg.type.kind === "dyn", weight); + return; + } + break; + case "dynCheck": + if (e.type.kind === "f64" && e.preserveRefs === undefined) { + expr(e.value, true, weight); + return; + } + break; + case "toBool": + if (e.operand.type.kind === "dyn") { + expr(e.operand, true, weight); + return; + } + break; + default: + break; + } + blockExprReferences(e); + everyExprChild( + e, + (child) => { + expr(child, false, weight); + return true; + }, + (s) => { + stmt(s, weight); + return true; + }, + ); + }; + /** Locals referenced other than by reads and plain writes (increments, + * loop and catch bindings, rethrows, closure captures) keep dyn. */ + const blockExprReferences = (e: IrExpr): void => { + if (e.kind === "incDec") blocked.add(e.localId); + else if (e.kind === "closure") for (const id of e.captures) blocked.add(id); + else if (e.kind === "classRef") for (const id of e.captures ?? []) blocked.add(id); + }; + const blockStmtReferences = (s: IrStmt): void => { + if (s.kind === "forOf" || s.kind === "rethrow") blocked.add(s.localId); + else if (s.kind === "tryCatch" && s.catchLocalId !== null) blocked.add(s.catchLocalId); + }; + const stmt = (s: IrStmt, weight: number): void => { + switch (s.kind) { + case "return": + if (s.value !== null) expr(s.value, jsResult && s.value.type.kind === "dyn", weight); + return; + case "varDecl": + write(s.localId, s.init, weight); + if (s.init !== null) expr(s.init, false, weight); + return; + case "assign": + if (s.initializes !== undefined) blocked.add(s.localId); + write(s.localId, s.value, weight); + expr(s.value, false, weight); + return; + case "while": + case "doWhile": + case "for": + case "forOf": { + blockStmtReferences(s); + const inner = Math.min(weight * LOOP_WEIGHT, MAX_WEIGHT); + everyStmtChild( + s, + (child) => { + expr(child, false, inner); + return true; + }, + (child) => { + stmt(child, inner); + return true; + }, + ); + return; + } + default: + blockStmtReferences(s); + everyStmtChild( + s, + (child) => { + expr(child, false, weight); + return true; + }, + (child) => { + stmt(child, weight); + return true; + }, + ); + } + }; + for (const s of fn.body) stmt(s, 1); + const promoted = new Set(); + for (const [id, cost] of candidates) + if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); + return promoted; +} + +/* ── the rewrite ─────────────────────────────────────────────────────── */ + +function promoteFunction(fn: IrFunction, results: ReadonlySet): boolean { + const resultCall = (e: IrExpr): boolean => e.kind === "call" && results.has(e.callee); + const operatorCall = (e: IrExpr): boolean => isProducerCall(e); + const promoted = promotableLocals( + fn, + (e) => operatorCall(e) || resultCall(e), + results.has(fn.name), + ); + const ownResult = results.has(fn.name); + let changed = false; + const producesJs = (e: IrExpr): boolean => + operatorCall(e) || + resultCall(e) || + ((e.kind === "varRef" || e.kind === "assignExpr") && promoted.has(e.localId)); + const libCall = (fn: string, args: IrExpr[], e: IrExpr, type = JSVAL): IrExpr => ({ + kind: "libCall", + fn: fn as Extract["fn"], + args, + type, + loc: e.loc, + }); + /** A dyn-typed expression as a jsval. */ + const toJs = (e: IrExpr): IrExpr => { + changed = true; + if (e.kind === "call" && results.has(e.callee)) + return { ...e, args: e.args.map(rewrite), type: JSVAL }; + if (e.kind === "libCall" && Object.hasOwn(PRODUCERS, e.fn)) { + return libCall( + PRODUCERS[e.fn]!, + e.args.map((arg) => (arg.type.kind === "dyn" ? toJs(arg) : rewrite(arg))), + e, + ); + } + if (isImmediateSource(e) && e.kind === "dynFrom") { + const value = rewrite(e.value); + return libCall(STATIC_SOURCES[value.type.kind]!, [value], e); + } + if (e.kind === "varRef" && promoted.has(e.localId)) return { ...e, type: JSVAL }; + if (e.kind === "assignExpr" && promoted.has(e.localId)) + return { ...e, value: toJs(e.value), type: JSVAL }; + return libCall("js.fromDyn", [rewrite(e)], e); + }; + /** Any expression, in a position expecting its own type. */ + const rewrite = (e: IrExpr): IrExpr => { + if (e.kind === "libCall" && Object.hasOwn(CONSUMERS, e.fn)) { + changed = true; + return libCall( + CONSUMERS[e.fn]!, + e.args.map((arg) => (arg.type.kind === "dyn" ? toJs(arg) : rewrite(arg))), + e, + e.type, + ); + } + if ( + e.kind === "dynCheck" && + e.type.kind === "f64" && + e.preserveRefs === undefined && + producesJs(e.value) + ) + return libCall("js.toF64", [toJs(e.value)], e, e.type); + if (e.kind === "toBool" && e.operand.type.kind === "dyn" && producesJs(e.operand)) + return { ...e, operand: toJs(e.operand) }; + if (e.type.kind === "dyn" && producesJs(e)) return libCall("js.toDyn", [toJs(e)], e, DYN); + return mapExprChildren(e, rewrite, rewriteStmt); + }; + const rewriteStmt = (s: IrStmt): IrStmt => { + if (s.kind === "return" && ownResult && s.value !== null) return { ...s, value: toJs(s.value) }; + // A discarded result stays a jsval: nothing boxes it. + if (s.kind === "exprStmt" && producesJs(s.expr)) return { ...s, expr: toJs(s.expr) }; + if (s.kind === "varDecl" && promoted.has(s.localId) && s.init !== null) + return { ...s, init: toJs(s.init) }; + if (s.kind === "assign" && promoted.has(s.localId)) return { ...s, value: toJs(s.value) }; + return mapStmtChildren(s, rewrite, rewriteStmt); + }; + const body = fn.body.map(rewriteStmt); + if (!changed) return false; + fn.body = body; + for (const local of fn.locals) if (promoted.has(local.id)) local.type = JSVAL; + return true; +} diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index d16edfabd..abd09d181 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -24,6 +24,9 @@ export interface RuntimeFeatures { 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; inspect: boolean; childProcess: boolean; net: boolean; @@ -196,6 +199,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run mod.functions.some( (fn) => fn.async === true && fn.generator === undefined && fn.returnType.kind === "dyn", ), + dynTier: false, inspect: false, childProcess: false, net: false, @@ -226,6 +230,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run const expr = (node: IrExpr): boolean => { if (node.kind === "libCall") { const fn = node.fn; + if (fn.startsWith("js.")) features.dynTier = true; if ( fn === "regexp.escape" || fn === "dyn.nativeRegexIs" || @@ -862,3 +867,10 @@ export function moduleUsesTls(mod: IrModule): boolean { export function moduleUsesTlsCa(mod: IrModule): boolean { return scanRuntimeFeatures(mod, "tlsCa").tlsCa; } + +/** True when the module has dynamic-tier jsval operations: the link switch + * for the scr_js_* runtime units (two-tier design I3). Programs without + * them never link a scr_js_ symbol. */ +export function moduleUsesDynTier(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "dynTier").dynTier; +} diff --git a/packages/compiler/src/ir/scalar-records.test.ts b/packages/compiler/src/ir/scalar-records.test.ts index 551e7567a..e3132088d 100644 --- a/packages/compiler/src/ir/scalar-records.test.ts +++ b/packages/compiler/src/ir/scalar-records.test.ts @@ -40,7 +40,7 @@ const read = (localId: string, field = "a"): IrExpr => ({ function fixture(): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", records: [ diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index 1d0e5364e..ca5eb955e 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -7,11 +7,10 @@ * text parts and decoded from bytes one function at a time; neither direction * materializes the complete document as a single string. */ import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; -import { deriveModuleTiers } from "./tiers.js"; -// Version 16 removes embedded-engine types, operations, and module metadata. -// Older modules can require runtime support that no longer exists. -export const IR_VERSION = 16 as const; +// Version 17 adds the dynamic tier's jsval type and js.* builtins (two-tier +// design stage S3) and serializes each function's execution tier. +export const IR_VERSION = 17 as const; /** IR artifacts are plain data. Scan each function once before deciding * whether its JSON encoding needs the complete-number-domain replacer. */ @@ -43,16 +42,7 @@ function encodeNumber(_key: string, value: unknown): unknown { return value; } -/** Stage S2 of the two-tier design keeps function tiers out of IR - * artifacts, so a report-only release changes no serialized byte; loaders - * derive them again (deriveModuleTiers). No IR node other than a function - * carries a `tier` key. */ -function encodeWithoutTier(key: string, value: unknown): unknown { - return key === "tier" ? undefined : encodeNumber(key, value); -} - function encodeFunction(fn: IrFunction): string | undefined { - if (fn.tier !== undefined) return JSON.stringify(fn, encodeWithoutTier); return ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber); } @@ -82,7 +72,7 @@ export function serializeModuleParts(mod: IrModule): string[] { * readable default. Both return one string, so modules whose encoding * exceeds the host's string limit must use `serializeModuleParts`. */ export function serializeModule(mod: IrModule, compact = false): string { - if (!compact) return JSON.stringify(mod, encodeWithoutTier, 2); + if (!compact) return JSON.stringify(mod, encodeNumber, 2); return serializeModuleParts(mod).join(""); } @@ -108,7 +98,7 @@ function checkedVersion(mod: IrModule): IrModule { } export function deserializeModule(json: string): IrModule { - return deriveModuleTiers(checkedVersion(JSON.parse(json, decodeNumber) as IrModule)); + return checkedVersion(JSON.parse(json, decodeNumber) as IrModule); } /** A node as it reads back from the compact encoding: a fresh object, as @@ -261,5 +251,5 @@ export function deserializeModuleBytes(bytes: Buffer): IrModule { ); } mod.functions = functions; - return deriveModuleTiers(mod); + return mod; } diff --git a/packages/compiler/src/ir/tiers.test.ts b/packages/compiler/src/ir/tiers.test.ts index c5f1ad4b8..6f2f0c395 100644 --- a/packages/compiler/src/ir/tiers.test.ts +++ b/packages/compiler/src/ir/tiers.test.ts @@ -1,5 +1,15 @@ import { expect, test } from "vitest"; -import { BOOL, DYN, F64, STRING, VOID, type IrExpr, type IrFunction, type IrModule } from "./ir.js"; +import { + BOOL, + DYN, + F64, + JSVAL, + STRING, + VOID, + type IrExpr, + type IrFunction, + type IrModule, +} from "./ir.js"; import { deserializeModule, deserializeModuleBytes, @@ -37,7 +47,7 @@ function moduleOf(...functions: IrFunction[]): IrModule { body: [], loc, }; - return { irVersion: 16, sourceFile: "tiers.ts", functions: [main, ...functions], entry: "%main" }; + return { irVersion: 17, sourceFile: "tiers.ts", functions: [main, ...functions], entry: "%main" }; } const keyGet: IrExpr = { @@ -123,20 +133,56 @@ test("the validator enforces I1: static functions perform no dynamic operation", ]); }); -test("serialized IR carries no tiers in stage S2, and loading derives them again", () => { +test("serialized IR carries each function's tier", () => { const reader = fn("reader", keyGet); const mod = moduleOf(reader); - const untiered = serializeModule(mod, true); assignLegacyTiers(mod.functions); expect(reader.tier).toBe("dynamic"); - expect(serializeModule(mod, true)).toBe(untiered); - expect(serializeModuleParts(mod).join("")).toBe(untiered); - expect(serializeModule(mod)).not.toContain('"tier"'); - for (const loaded of [ - deserializeModule(untiered), - deserializeModuleBytes(Buffer.from(untiered)), - ]) { + const tiered = serializeModule(mod, true); + expect(serializeModuleParts(mod).join("")).toBe(tiered); + expect(tiered).toContain('"tier":"dynamic"'); + for (const loaded of [deserializeModule(tiered), deserializeModuleBytes(Buffer.from(tiered))]) { expect(loaded.functions.map(functionTier)).toEqual(["static", "dynamic"]); expect(validateModule(loaded)).toEqual([]); } + // A loader trusts the serialized tier: the validator then checks I1. + const stripped = deserializeModule(tiered.replace(',"tier":"dynamic"', "")); + expect(validateModule(stripped).map((e) => e.message)).toEqual([ + "in reader: static-tier function performs the dynamic operation dynKeyGet", + ]); +}); + +test("jsval operators are dynamic operations and jsval conversions are boundaries", () => { + const js: IrExpr = { kind: "libCall", fn: "js.fromDyn", args: [dynParam], type: JSVAL, loc }; + expect(dynamicOperationOf(js)).toBeNull(); + expect( + dynamicOperationOf({ kind: "libCall", fn: "js.toDyn", args: [js], type: DYN, loc }), + ).toBeNull(); + expect( + dynamicOperationOf({ kind: "libCall", fn: "js.toF64", args: [js], type: F64, loc }), + ).toBeNull(); + expect( + dynamicOperationOf({ + kind: "libCall", + fn: "js.fromF64", + args: [{ kind: "numLit", value: 1, type: F64, loc }], + type: JSVAL, + loc, + }), + ).toBeNull(); + expect( + dynamicOperationOf({ kind: "libCall", fn: "js.add", args: [js, js], type: JSVAL, loc }), + ).toBe("js.add"); + const sum = fn("sum", { + kind: "libCall", + fn: "js.toDyn", + args: [{ kind: "libCall", fn: "js.add", args: [js, js], type: JSVAL, loc }], + type: DYN, + loc, + }); + expect(validateModule(moduleOf(sum)).map((e) => e.message)).toEqual([ + "in sum: static-tier function performs the dynamic operation js.add", + ]); + sum.tier = "dynamic"; + expect(validateModule(moduleOf(sum))).toEqual([]); }); diff --git a/packages/compiler/src/ir/tiers.ts b/packages/compiler/src/ir/tiers.ts index 201bc2a58..ce2fdf0f4 100644 --- a/packages/compiler/src/ir/tiers.ts +++ b/packages/compiler/src/ir/tiers.ts @@ -24,6 +24,11 @@ * - calls of non-`dyn.*` builtins whose signatures take or return `dyn` * (typed APIs implemented through dyn, leak L6 of the design). They stay * boundaries until stage S1 gives them typed signatures. + * - the jsval conversions (stage S3): `js.fromF64`, `js.fromBool`, + * `js.fromStr` and `js.fromBigInt` (static to jsval), `js.toF64` (jsval to number), and + * `js.fromDyn`/`js.toDyn`, which move a value between the two dynamic + * representations. Every other `js.*` builtin (the jsval operators) is a + * dynamic operation. * * Classification of existing code. The `dyn` vocabulary is today's * dynamic tier (`ScrDyn`): `any`, untyped JavaScript, unschematized @@ -34,10 +39,9 @@ * do not follow from a body; until then the classification is exact, and * the validator guards it against later passes and hand-written IR. * - * Serialization. Stage S2 must not change any artifact, so serialized IR - * omits `tier` (serialize.ts) and loaders derive it again with the same - * classification. The next IR version serializes the field. */ -import type { IrExpr, IrFunction, IrModule, SrcLoc } from "./ir.js"; + * Serialization. Since IR version 17 (stage S3) serialized IR carries + * `tier` like any other function field. */ +import type { IrExpr, IrFunction, SrcLoc } from "./ir.js"; import { everyStmtList, type IrVisitor } from "./traverse.js"; export type IrTier = "static" | "dynamic"; @@ -46,6 +50,18 @@ export function functionTier(fn: IrFunction): IrTier { return fn.tier === "dynamic" ? "dynamic" : "static"; } +/** `js.*` builtins that convert at the boundary instead of operating on a + * dynamic value. */ +const JS_BOUNDARY: ReadonlySet = new Set([ + "js.fromDyn", + "js.toDyn", + "js.fromF64", + "js.fromBool", + "js.fromStr", + "js.fromBigInt", + "js.toF64", +]); + /** `dyn.*` builtins that only inspect a dynamic value's kind: the tag test * half of a dynamic-to-static conversion. */ const DYN_KIND_TESTS: ReadonlySet = new Set([ @@ -87,6 +103,7 @@ export function dynamicOperationOf(e: IrExpr): string | null { // `a || b` over dyn operands applies ToBoolean to a dynamic value. return e.type.kind === "dyn" ? "logical" : null; case "libCall": + if (e.fn.startsWith("js.")) return JS_BOUNDARY.has(e.fn) ? null : e.fn; return e.fn.startsWith("dyn.") && !DYN_KIND_TESTS.has(e.fn) ? e.fn : null; case "call": // Generated `%dyn.*` helpers are dynamic operations lowered out of @@ -148,10 +165,3 @@ export function assignLegacyTiers(functions: readonly IrFunction[]): DynamicFunc } return dynamic; } - -/** Loaders call this on deserialized modules (stage S2 serializes no - * tiers). */ -export function deriveModuleTiers(mod: IrModule): IrModule { - assignLegacyTiers(mod.functions); - return mod; -} diff --git a/packages/compiler/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index 1ef70bdb2..4c4334868 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -52,6 +52,7 @@ export function everyTypeChild(node: IrType, visit: (node: IrType) => boolean): case "record": case "union": case "dyn": + case "jsval": case "caught": case "undefinedT": case "nullT": diff --git a/packages/compiler/src/ir/unshare.test.ts b/packages/compiler/src/ir/unshare.test.ts index fdbd2619c..40368a836 100644 --- a/packages/compiler/src/ir/unshare.test.ts +++ b/packages/compiler/src/ir/unshare.test.ts @@ -57,7 +57,7 @@ function sharedRead(): { mod: IrModule; read: IrExpr } { }, ]; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 8086cc176..a244886c0 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -146,6 +146,7 @@ test("class prototype data helpers retain their ABI after serialization", () => body: [{ kind: "return", value: { kind: "dynObjLit", fields: [], type: DYN, loc }, loc }], loc, }); + assignLegacyTiers(mod.functions); expect(validateModule(deserializeModule(serializeModule(mod)))).toEqual([]); for (const variant of ["missing", "params", "return", "captures"]) { const bad = structuredClone(mod); @@ -178,6 +179,7 @@ test("instance prototype helpers require the class receiver ABI", () => { body: [{ kind: "return", value: { kind: "dynObjLit", fields: [], type: DYN, loc }, loc }], loc, }); + assignLegacyTiers(mod.functions); expect(validateModule(deserializeModule(serializeModule(mod)))).toEqual([]); for (const variant of ["missing", "params", "receiver", "return", "captures"]) { const bad = structuredClone(mod); @@ -317,7 +319,7 @@ function numericReadModule(overrides: Partial ...overrides, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -401,7 +403,7 @@ test.each(["sortPrimitive", "toSortedPrimitive"] as const)( function expressionModule(expr: IrExpr, unions: IrUnionDef[]): IrModule { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", unions, @@ -1314,7 +1316,7 @@ test.each([ function tdzModule(mutable = true): IrModule { const value: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [ @@ -1607,7 +1609,7 @@ test("TDZ locals require a shared box", () => { function discriminatedModule(): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }], @@ -2124,7 +2126,7 @@ test("a caught-typed global records a module error only from a catch binding", ( const caught: IrType = { kind: "caught" }; const errorId = "%g.e.%loaded%error"; const module = (value: IrExpr): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", unions: [], diff --git a/packages/compiler/src/library/fence-eval.test.ts b/packages/compiler/src/library/fence-eval.test.ts index 7b4f03434..bac54c5bb 100644 --- a/packages/compiler/src/library/fence-eval.test.ts +++ b/packages/compiler/src/library/fence-eval.test.ts @@ -15,7 +15,7 @@ function program(member: string): IrModule { loc, }; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "probe", functions: [ diff --git a/packages/compiler/src/library/int-infer.test.ts b/packages/compiler/src/library/int-infer.test.ts index 250bdc6d8..e5283c56e 100644 --- a/packages/compiler/src/library/int-infer.test.ts +++ b/packages/compiler/src/library/int-infer.test.ts @@ -97,7 +97,7 @@ const sink = (name: string): IrFunction => ({ /** A module holding the case function plus the two declared sinks. */ function caseModule(params: string[], locals: string[], body: IrStmt[]): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: "corpus.ts", functions: [ sink("send"), @@ -196,7 +196,7 @@ const RECORD_CFG: IntSlotConfig = { function recordCase(body: IrStmt[], names = ["m"], extraFns: IrFunction[] = []): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: "fields.ts", functions: [ ...extraFns, @@ -237,7 +237,7 @@ const classCountRead = (): IrExpr => ({ function onlyOrdinaryClass(body: IrStmt[]): IntVerdict { const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "class-fields.ts", functions: [ sink("send"), @@ -581,7 +581,7 @@ describe("the domain's edges beyond the corpus", () => { loc, }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "optional.ts", functions: [ { diff --git a/packages/compiler/src/library/library-cache.test.ts b/packages/compiler/src/library/library-cache.test.ts index 9a3e4e4cf..846d90272 100644 --- a/packages/compiler/src/library/library-cache.test.ts +++ b/packages/compiler/src/library/library-cache.test.ts @@ -261,7 +261,7 @@ test("semantic library cache restores and rebases IR after a comment-only edit", const sourceBefore = await readFile(f.source, "utf8"); const returnStart = sourceBefore.indexOf("return"); const semanticMod = { - irVersion: 16, + irVersion: 17, sourceFile: f.source, functions: [ { @@ -324,7 +324,7 @@ test("semantic library cache refuses token and directive edits", async () => { const f = await fixture(); const sourceBefore = await readFile(f.source, "utf8"); const semanticMod = { - irVersion: 16, + irVersion: 17, sourceFile: f.source, functions: [ { @@ -372,7 +372,7 @@ test("semantic LLVM cache accepts comment edits that shift source lines", async const f = await fixture(); const sourceBefore = await readFile(f.source, "utf8"); const semanticMod = { - irVersion: 16, + irVersion: 17, sourceFile: f.source, functions: [ { @@ -447,7 +447,7 @@ test("semantic LLVM cache accepts line separator normalization", async () => { frontend: tracker.snapshot(), semantic: { mod: { - irVersion: 16, + irVersion: 17, sourceFile: f.source, functions: [], entry: "__main", @@ -468,7 +468,7 @@ test("semantic LLVM cache rebases comment edits in multi-source graphs", async ( const importedSource = "export function helper(): number { return 1; }\n"; await writeFile(imported, importedSource); const semanticMod = { - irVersion: 16, + irVersion: 17, sourceFile: f.source, functions: [ { diff --git a/packages/compiler/src/library/library-cache.ts b/packages/compiler/src/library/library-cache.ts index 1b65024e0..356056dd8 100644 --- a/packages/compiler/src/library/library-cache.ts +++ b/packages/compiler/src/library/library-cache.ts @@ -63,6 +63,7 @@ interface EarlyLibraryCacheStamp { export interface EarlyLibraryNativeFeatures { backend: "llvm"; dynInvoke: boolean; + dynTier?: boolean; regex: boolean; assert: boolean; inspect: boolean; diff --git a/packages/compiler/src/native/compiler.test.ts b/packages/compiler/src/native/compiler.test.ts index 8203271eb..7a1cee62a 100644 --- a/packages/compiler/src/native/compiler.test.ts +++ b/packages/compiler/src/native/compiler.test.ts @@ -60,7 +60,7 @@ test.each([ writeFileSync(output, "previous executable"); writeFileSync(llvmPath, "previous LLVM"); const module: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: entry, entry: "%main", functions: [ @@ -197,7 +197,7 @@ test.each([false, true])( writeFileSync(output, "previous object"); writeFileSync(llvmPath, "previous LLVM"); const module: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: entry, entry: "%main", functions: [ diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 9047f3376..e0991c372 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -74,6 +74,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( features: { workers: false, eventTarget: false, + dynTier: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index ad47cc2a7..9a241b854 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -75,7 +75,7 @@ function fixture(): IrModule { ); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [{ name: "__main", params: [], returnType: VOID, locals, body, loc }], @@ -145,7 +145,7 @@ function receiverReassignmentFixture(): IrModule { ]; return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [{ name: "__main", params: [], returnType: VOID, locals, body, loc }], @@ -212,7 +212,7 @@ function integerLoopFixture(mutatesIndex = false): IrModule { { kind: "bytesSet", arr: bytesRef(), index: indexRef(), value: ref("sum"), loc }, ); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [ @@ -365,7 +365,7 @@ test("large record clones stay outlined while small clones remain inlineable", ( const fields = Array.from({ length: count }, (_, i) => ({ name: `f${i}`, type: F64 })); const type = { kind: "record", shapeId: id } as const; return { - irVersion: 16, + irVersion: 17, sourceFile: "record-clone.ts", entry: "__main", records: [{ id, fields }], diff --git a/packages/compiler/test/emit-llvm.test.ts b/packages/compiler/test/emit-llvm.test.ts index 2f1366b43..73689d541 100644 --- a/packages/compiler/test/emit-llvm.test.ts +++ b/packages/compiler/test/emit-llvm.test.ts @@ -35,7 +35,7 @@ test("strings: literals, concat in a loop, toString, RC-clean under audit", asyn // while (i < 3) { acc = acc + ("-" + i); i = i + 1; } // console.log(acc, acc === "x-0-1-2", "α∂" < "β"); const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "s.ts", entry: "__main", functions: [ @@ -137,7 +137,7 @@ test("short-circuit: right operand of && only evaluates when left is true", asyn // if (false && sideEffect()) {} ; if (true || sideEffect()) {} // console.log("done") const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "l.ts", entry: "__main", functions: [ @@ -227,7 +227,7 @@ test("string params: callee owns and releases; returns transfer ownership", asyn // function greet(who: string): string { return "hi " + who; } // console.log(greet("world")); const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "p.ts", entry: "__main", functions: [ diff --git a/packages/compiler/test/fixtures/fib-ir.ts b/packages/compiler/test/fixtures/fib-ir.ts index 8daaa91d5..5be37a8bb 100644 --- a/packages/compiler/test/fixtures/fib-ir.ts +++ b/packages/compiler/test/fixtures/fib-ir.ts @@ -17,7 +17,7 @@ const n = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, lo const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); export const fibModule: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "fib.ts", entry: "__main", functions: [ diff --git a/packages/compiler/test/fromcharcode-emission.test.ts b/packages/compiler/test/fromcharcode-emission.test.ts index bab64ce3e..2df4131e8 100644 --- a/packages/compiler/test/fromcharcode-emission.test.ts +++ b/packages/compiler/test/fromcharcode-emission.test.ts @@ -7,7 +7,7 @@ test("scalar fromCharCode avoids a heap argument pack in LLVM", () => { const loc = { file: "scalar-char.ts", start: 0, end: 0 }; const value: IrExpr = { kind: "numLit", value: 65, type: F64, loc }; const mod: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", globals: [], diff --git a/packages/compiler/test/ir.test.ts b/packages/compiler/test/ir.test.ts index 7d7efdb01..7f1a1ef78 100644 --- a/packages/compiler/test/ir.test.ts +++ b/packages/compiler/test/ir.test.ts @@ -22,7 +22,7 @@ test("fib module JSON round-trips", () => { test("validator rejects type mismatches and bad references", () => { const loc = { file: "t.ts", start: 0, end: 0 }; const bad: IrModule = { - irVersion: 16, + irVersion: 17, sourceFile: "t.ts", entry: "__main", functions: [ @@ -108,7 +108,7 @@ test("serializer round-trips ±Infinity and NaN", () => { }); test("deserializer rejects the previous IR version", () => { - const json = serializeModule(fibModule).replace('"irVersion": 16', '"irVersion": 13'); + const json = serializeModule(fibModule).replace('"irVersion": 17', '"irVersion": 13'); expect(() => deserializeModule(json)).toThrow(/version mismatch/); }); diff --git a/packages/compiler/test/llvm-lib-dispatch.test.ts b/packages/compiler/test/llvm-lib-dispatch.test.ts index 174c54afa..d417fd5b4 100644 --- a/packages/compiler/test/llvm-lib-dispatch.test.ts +++ b/packages/compiler/test/llvm-lib-dispatch.test.ts @@ -19,7 +19,7 @@ const dyn = (): IrExpr => ({ kind: "dynFrom", value: str("value"), type: DYN, lo function libCallModule(fn: IrLibFn, args: IrExpr[], type: IrType): IrModule { return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", functions: [ diff --git a/packages/compiler/test/llvm-runtime-abi.test.ts b/packages/compiler/test/llvm-runtime-abi.test.ts index 9a7ccbb2f..6cfd862b7 100644 --- a/packages/compiler/test/llvm-runtime-abi.test.ts +++ b/packages/compiler/test/llvm-runtime-abi.test.ts @@ -176,7 +176,10 @@ async function readRuntimeHeader( * no linkage symbol the emitter could declare, so a declare against one * must report as missing. */ async function parseHeader(): Promise<{ protos: Map; dataSyms: Set }> { - const raw = await readRuntimeHeader(); + // The dynamic tier's units declare their entry points in scr_js.h, which + // static runtime units never include (packages/runtime/test/headers). + const seen = new Set(); + const raw = `${await readRuntimeHeader("scr_runtime.h", seen)}\n${await readRuntimeHeader("scr_js.h", seen)}`; const src = raw .replace(/\\\r?\n/g, "") .replace(/\/\*[\s\S]*?\*\//g, " ") @@ -192,6 +195,12 @@ async function parseHeader(): Promise<{ protos: Map; dataSyms: S for (const m of src.matchAll(/typedef\s+[^;{()]*\(\s*\*\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*\(/g)) { ptrTypedefs.add(m[1]!); } + // Object-pointer typedefs (ScrJsVal). + for (const m of src.matchAll( + /typedef\s+[A-Za-z_][A-Za-z0-9_ ]*\*\s*([A-Za-z_][A-Za-z0-9_]*)\s*;/g, + )) { + ptrTypedefs.add(m[1]!); + } const types: HeaderTypes = { enums, ptrTypedefs }; const protos = new Map(); for (const m of src.matchAll(/\b(scr_[a-z0-9_]+)\s*\(/g)) { diff --git a/packages/compiler/test/string-accumulation-emission.test.ts b/packages/compiler/test/string-accumulation-emission.test.ts index 36652c912..c58c8c0e3 100644 --- a/packages/compiler/test/string-accumulation-emission.test.ts +++ b/packages/compiler/test/string-accumulation-emission.test.ts @@ -86,7 +86,7 @@ function fixture(): IrModule { .find((fn) => fn.name === "negative")! .body.splice(1, 0, { kind: "varDecl", localId: "other", init: str("other"), loc }); return { - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "__main", globals: [{ id: "%g.e.acc", name: "globalAccumulator", type: STRING, mutable: true }], diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6146-jsval-operators.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6146-jsval-operators.js.json new file mode 100644 index 000000000..f3c7509d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6146-jsval-operators.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6146-jsval-operators.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6147-jsval-any-operators.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6147-jsval-any-operators.ts.json new file mode 100644 index 000000000..5abd88146 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6147-jsval-any-operators.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6147-jsval-any-operators.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 54e789624..7a491afb2 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -67,6 +67,9 @@ const optional = [ ["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. diff --git a/packages/runtime-pack-common/library-matrix.mjs b/packages/runtime-pack-common/library-matrix.mjs index 4f14d959b..dd3071339 100644 --- a/packages/runtime-pack-common/library-matrix.mjs +++ b/packages/runtime-pack-common/library-matrix.mjs @@ -15,6 +15,7 @@ export function withLibraryRuntimeFlavors(matrix) { "scr_zlib.c", "scr_copying.c", "scr_dyn_invoke.c", + "scr_js_value.c", ]); const libraryUnits = [ ...matrix.runtime_units.filter( diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6b5052482..d1fe7690e 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -57,6 +57,9 @@ const OPTIONAL = [ ["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. diff --git a/packages/runtime/src/scr_js.h b/packages/runtime/src/scr_js.h new file mode 100644 index 000000000..5af849757 --- /dev/null +++ b/packages/runtime/src/scr_js.h @@ -0,0 +1,183 @@ +/* scriptc runtime — dynamic-tier values (jsval): the value representation of + * the two-tier design's dynamic tier (scr_js_value.c). Only dynamic-tier + * units (scr_js_*.c) include this header: static runtime units never see a + * jsval, and the compiler links these units only into programs whose IR has + * a dynamic-tier operation (the dynTier runtime feature). The header test + * enforces the include rule. + * + * A jsval is one 64-bit NaN-boxed word. The compiler passes it as an LLVM + * `ptr`, so the C API spells it `ScrJsVal` (void *) and reads the bits + * through scr_js_bits: + * + * bits below SCR_JS_DOUBLE_LIMIT an IEEE double; every NaN is the + * canonical 0x7FF8000000000000 + * 0xFFF9 << 48 | n undefined (0), null (1), false (2), + * true (3) + * 0xFFFA << 48 | ptr a ScrDyn cell (objects, arrays, + * functions, capsules and every other + * checked-dynamic value; a NUM, BOOL, + * NULL or UNDEF cell is a valid jsval and + * means its value) + * 0xFFFB << 48 | ptr a string: the static tier's ScrStr + * 0xFFFC << 48 | ptr a BigInt: the static tier's ScrBigInt + * 0xFFFD, 0xFFFE reserved (symbols; int32 immediates) + * + * The three heap tags are contiguous, so "does this word own a reference" + * is one subtract and one compare. Pointers fit in 48 bits on every 64-bit + * target scriptc supports. On 32-bit targets (wasm32) a pointer cannot hold + * a double, so a jsval there is a ScrDyn cell pointer: every number is a + * cell, and these helpers keep the same API over that boxed form. + * + * Ownership follows the dyn conventions: arguments are borrowed, results + * are owned (+1), and a call that throws leaves the exception pending and + * returns undefined. */ +#ifndef SCR_JS_H +#define SCR_JS_H + +#include "scr_dyn.h" +#include "scr_number.h" + +#include +#include +#include + +typedef void *ScrJsVal; + +#if UINTPTR_MAX > 0xFFFFFFFFu +#define SCR_JS_IMMEDIATES 1 +#else +#define SCR_JS_IMMEDIATES 0 +#endif + +#if SCR_JS_IMMEDIATES + +#define SCR_JS_TAG_SHIFT 48 +#define SCR_JS_TAG_SPECIAL UINT64_C(0xFFF9) +#define SCR_JS_TAG_CELL UINT64_C(0xFFFA) +#define SCR_JS_TAG_STR UINT64_C(0xFFFB) +#define SCR_JS_TAG_BIGINT UINT64_C(0xFFFC) +#define SCR_JS_DOUBLE_LIMIT (SCR_JS_TAG_SPECIAL << SCR_JS_TAG_SHIFT) +#define SCR_JS_PAYLOAD_MASK ((UINT64_C(1) << SCR_JS_TAG_SHIFT) - 1) +#define SCR_JS_CANONICAL_NAN UINT64_C(0x7FF8000000000000) +#define SCR_JS_UNDEFINED_BITS (SCR_JS_DOUBLE_LIMIT | 0) +#define SCR_JS_NULL_BITS (SCR_JS_DOUBLE_LIMIT | 1) +#define SCR_JS_FALSE_BITS (SCR_JS_DOUBLE_LIMIT | 2) +#define SCR_JS_TRUE_BITS (SCR_JS_DOUBLE_LIMIT | 3) + +static inline uint64_t scr_js_bits(ScrJsVal v) { return (uint64_t)(uintptr_t)v; } +static inline ScrJsVal scr_js_of_bits(uint64_t bits) { return (ScrJsVal)(uintptr_t)bits; } +static inline uint64_t scr_js_tag(ScrJsVal v) { return scr_js_bits(v) >> SCR_JS_TAG_SHIFT; } +static inline void *scr_js_payload(ScrJsVal v) { + return (void *)(uintptr_t)(scr_js_bits(v) & SCR_JS_PAYLOAD_MASK); +} + +static inline bool scr_js_is_double(ScrJsVal v) { return scr_js_bits(v) < SCR_JS_DOUBLE_LIMIT; } +static inline double scr_js_double(ScrJsVal v) { + double d; + uint64_t bits = scr_js_bits(v); + memcpy(&d, &bits, sizeof d); + return d; +} +static inline ScrJsVal scr_js_from_double(double d) { + uint64_t bits; + memcpy(&bits, &d, sizeof bits); + return scr_js_of_bits(d != d ? SCR_JS_CANONICAL_NAN : bits); +} +static inline bool scr_js_is_heap(ScrJsVal v) { + return scr_js_tag(v) - SCR_JS_TAG_CELL <= SCR_JS_TAG_BIGINT - SCR_JS_TAG_CELL; +} +static inline bool scr_js_is_cell(ScrJsVal v) { return scr_js_tag(v) == SCR_JS_TAG_CELL; } +static inline ScrDyn *scr_js_cell(ScrJsVal v) { return (ScrDyn *)scr_js_payload(v); } +static inline ScrJsVal scr_js_of_cell(ScrDyn *d) { + return scr_js_of_bits((SCR_JS_TAG_CELL << SCR_JS_TAG_SHIFT) | (uint64_t)(uintptr_t)d); +} +static inline bool scr_js_is_str(ScrJsVal v) { return scr_js_tag(v) == SCR_JS_TAG_STR; } +static inline ScrStr *scr_js_str(ScrJsVal v) { return (ScrStr *)scr_js_payload(v); } +static inline ScrJsVal scr_js_of_str(ScrStr *s) { + return scr_js_of_bits((SCR_JS_TAG_STR << SCR_JS_TAG_SHIFT) | (uint64_t)(uintptr_t)s); +} +static inline bool scr_js_is_bigint(ScrJsVal v) { return scr_js_tag(v) == SCR_JS_TAG_BIGINT; } +static inline ScrBigInt *scr_js_bigint(ScrJsVal v) { return (ScrBigInt *)scr_js_payload(v); } +static inline ScrJsVal scr_js_of_bigint(ScrBigInt *b) { + return scr_js_of_bits((SCR_JS_TAG_BIGINT << SCR_JS_TAG_SHIFT) | (uint64_t)(uintptr_t)b); +} +static inline ScrJsVal scr_js_undefined(void) { return scr_js_of_bits(SCR_JS_UNDEFINED_BITS); } +static inline ScrJsVal scr_js_null(void) { return scr_js_of_bits(SCR_JS_NULL_BITS); } +static inline ScrJsVal scr_js_bool(bool b) { + return scr_js_of_bits(b ? SCR_JS_TRUE_BITS : SCR_JS_FALSE_BITS); +} + +#else /* boxed: every jsval is a ScrDyn cell */ + +static inline bool scr_js_is_double(ScrJsVal v) { + (void)v; + return false; +} +static inline double scr_js_double(ScrJsVal v) { return ((ScrDyn *)v)->v.num; } +static inline ScrJsVal scr_js_from_double(double d) { return scr_dyn_new_num(d); } +static inline bool scr_js_is_heap(ScrJsVal v) { return v != NULL; } +static inline bool scr_js_is_cell(ScrJsVal v) { return v != NULL; } +static inline ScrDyn *scr_js_cell(ScrJsVal v) { return (ScrDyn *)v; } +static inline ScrJsVal scr_js_of_cell(ScrDyn *d) { return d; } +static inline bool scr_js_is_str(ScrJsVal v) { + (void)v; + return false; +} +static inline ScrStr *scr_js_str(ScrJsVal v) { return ((ScrDyn *)v)->v.str; } +static inline bool scr_js_is_bigint(ScrJsVal v) { + (void)v; + return false; +} +static inline ScrBigInt *scr_js_bigint(ScrJsVal v) { return ((ScrDyn *)v)->v.bigint; } +static inline ScrJsVal scr_js_undefined(void) { return scr_dyn_undefined(); } +static inline ScrJsVal scr_js_null(void) { return scr_dyn_new_null(); } +static inline ScrJsVal scr_js_bool(bool b) { return scr_dyn_new_bool(b); } + +#endif + +/* Whether a jsval is a number (an immediate double or a NUM cell), and its + * value. */ +static inline bool scr_js_number(ScrJsVal v, double *out) { + if (scr_js_is_double(v)) { + *out = scr_js_double(v); + return true; + } + if (scr_js_is_cell(v)) { + const ScrDyn *d = scr_js_cell(v); + if (d && d->kind == SCR_DYN_NUM) { + *out = d->v.num; + return true; + } + } + return false; +} + +/* ── reference counting (NULL-tolerant; immediates are unchanged) ───── */ +ScrJsVal scr_js_retain(ScrJsVal v); +void scr_js_release(ScrJsVal v); +void *scr_js_retain_v(void *v); +void scr_js_release_v(void *v); + +/* ── boundary conversions ────────────────────────────────────────────── + * dyn → jsval keeps a cell's identity under the cell tag (from_dyn + * consumes the cell); jsval → dyn boxes an immediate into a cell, a number + * through scr_dyn_new_num (to_dyn consumes, to_dyn_borrow borrows). */ +ScrJsVal scr_js_from_dyn(ScrDyn *d); +ScrDyn *scr_js_to_dyn(ScrJsVal v); +ScrDyn *scr_js_to_dyn_borrow(ScrJsVal v); +ScrJsVal scr_js_from_str(ScrStr *s); /* borrows and retains s */ +ScrJsVal scr_js_from_bigint(ScrBigInt *b); /* borrows and retains b */ + +/* ── operators (the dyn.* operators of scr_json.c over jsvals) ────────── + * Each implements its dyn twin's JS semantics exactly, including + * ToPrimitive order; the compiler inlines the number cases. */ +ScrJsVal scr_js_add(ScrJsVal left, ScrJsVal right); +ScrJsVal scr_js_arithmetic(ScrJsVal left, ScrJsVal right, const ScrStr *operation); +ScrJsVal scr_js_bitwise(ScrJsVal left, ScrJsVal right, const ScrStr *operation); +bool scr_js_compare(ScrJsVal left, ScrJsVal right, const ScrStr *operation); +ScrJsVal scr_js_to_numeric(ScrJsVal value); +ScrJsVal scr_js_increment(ScrJsVal value, bool increment); +double scr_js_to_number(ScrJsVal value); +bool scr_js_truthy(ScrJsVal value); + +#endif diff --git a/packages/runtime/src/scr_js_value.c b/packages/runtime/src/scr_js_value.c new file mode 100644 index 000000000..0313fb0cd --- /dev/null +++ b/packages/runtime/src/scr_js_value.c @@ -0,0 +1,318 @@ +/* scriptc runtime — dynamic-tier values (scr_js.h): reference counting, + * the dyn boundary conversions, and the generic operators over jsvals. + * + * Linked only into programs with dynamic-tier operations (the dynTier + * runtime feature). The compiler inlines the number cases of every + * operator and calls these entry points for the rest, so the generic + * paths reuse the checked-dynamic operators of scr_json.c: operands are + * lent to them as cells (an immediate is boxed for the call) and a number + * result comes back as an immediate. Every JS-visible behavior (ToPrimitive + * order, the object protocol, error messages) is therefore the dyn + * operator's own. */ +#include "scr_js.h" + +/* ── reference counting ──────────────────────────────────────────────── */ + +ScrJsVal scr_js_retain(ScrJsVal v) { + if (!scr_js_is_heap(v)) return v; +#if SCR_JS_IMMEDIATES + if (scr_js_is_str(v)) { + scr_str_retain(scr_js_str(v)); + return v; + } + if (scr_js_is_bigint(v)) { + scr_bigint_retain(scr_js_bigint(v)); + return v; + } +#endif + ScrDyn *d = scr_js_cell(v); + if (d) scr_dyn_retain(d); + return v; +} + +void scr_js_release(ScrJsVal v) { + if (!scr_js_is_heap(v)) return; +#if SCR_JS_IMMEDIATES + if (scr_js_is_str(v)) { + scr_str_release(scr_js_str(v)); + return; + } + if (scr_js_is_bigint(v)) { + scr_bigint_release(scr_js_bigint(v)); + return; + } +#endif + scr_dyn_release(scr_js_cell(v)); +} + +void *scr_js_retain_v(void *v) { return scr_js_retain(v); } + +void scr_js_release_v(void *v) { scr_js_release(v); } + +/* ── boundary conversions ────────────────────────────────────────────── */ + +ScrJsVal scr_js_from_str(ScrStr *s) { +#if SCR_JS_IMMEDIATES + return scr_js_of_str(scr_str_retain(s)); +#else + return scr_dyn_new_str(s); +#endif +} + +ScrJsVal scr_js_from_bigint(ScrBigInt *b) { +#if SCR_JS_IMMEDIATES + return scr_js_of_bigint(scr_bigint_retain(b)); +#else + return scr_dyn_new_bigint(b); +#endif +} + +ScrJsVal scr_js_from_dyn(ScrDyn *d) { return scr_js_of_cell(d); } + +ScrDyn *scr_js_to_dyn(ScrJsVal v) { +#if SCR_JS_IMMEDIATES + if (scr_js_is_double(v)) return scr_dyn_new_num(scr_js_double(v)); + switch (scr_js_tag(v)) { + case SCR_JS_TAG_CELL: return scr_js_cell(v); + case SCR_JS_TAG_STR: { + ScrStr *s = scr_js_str(v); + ScrDyn *d = scr_dyn_new_str(s); + scr_str_release(s); + return d; + } + case SCR_JS_TAG_BIGINT: { + ScrBigInt *b = scr_js_bigint(v); + ScrDyn *d = scr_dyn_new_bigint(b); + scr_bigint_release(b); + return d; + } + default: + switch (scr_js_bits(v)) { + case SCR_JS_NULL_BITS: return scr_dyn_new_null(); + case SCR_JS_FALSE_BITS: return scr_dyn_new_bool(false); + case SCR_JS_TRUE_BITS: return scr_dyn_new_bool(true); + default: return scr_dyn_undefined(); + } + } +#else + return scr_js_cell(v); +#endif +} + +ScrDyn *scr_js_to_dyn_borrow(ScrJsVal v) { return scr_js_to_dyn(scr_js_retain(v)); } + +/* An owned operator result as a jsval: a number, boolean or unit cell + * without own properties becomes its immediate (number results stay + * allocation-free downstream); every other cell keeps its identity. NULL + * (a pending throw) becomes undefined, which the caller's pending check + * discards. */ +static ScrJsVal scr_js_adopt(ScrDyn *d) { + if (!d) return scr_js_undefined(); +#if SCR_JS_IMMEDIATES + if (!d->prototype && !d->symbol_properties && !d->symbol_keys) { + ScrJsVal out; + switch (d->kind) { + case SCR_DYN_NUM: out = scr_js_from_double(d->v.num); break; + case SCR_DYN_BOOL: out = scr_js_bool(d->v.b); break; + case SCR_DYN_NULL: out = scr_js_null(); break; + case SCR_DYN_UNDEF: out = scr_js_undefined(); break; + default: return scr_js_of_cell(d); + } + scr_dyn_release(d); + return out; + } +#endif + return scr_js_of_cell(d); +} + +/* ── operators ───────────────────────────────────────────────────────── */ + +#if SCR_JS_IMMEDIATES +/* The string a jsval is (a string or a STR cell), borrowed; NULL otherwise. */ +static ScrStr *scr_js_string_of(ScrJsVal v) { + if (scr_js_is_str(v)) return scr_js_str(v); + if (scr_js_is_cell(v)) { + const ScrDyn *d = scr_js_cell(v); + if (d && d->kind == SCR_DYN_STR) return d->v.str; + } + return NULL; +} + +/* ToString of a primitive that ToPrimitive leaves unchanged (number, + * boolean, undefined, null, string): +1, or NULL for every other value + * (objects take the dyn operator's protocol, BigInts and symbols too). */ +static ScrStr *scr_js_primitive_string(ScrJsVal v) { + ScrStr *s = scr_js_string_of(v); + if (s) return scr_str_retain(s); + double n; + if (scr_js_number(v, &n)) return scr_f64_to_scrstr(n); + switch (scr_js_bits(v)) { + case SCR_JS_UNDEFINED_BITS: return scr_str_new("undefined", 9); + case SCR_JS_NULL_BITS: return scr_str_new("null", 4); + case SCR_JS_FALSE_BITS: return scr_str_new("false", 5); + case SCR_JS_TRUE_BITS: return scr_str_new("true", 4); + default: return NULL; + } +} + +/* The BigInt a jsval is (a BigInt or a BIGINT cell), borrowed; NULL + * otherwise. */ +static ScrBigInt *scr_js_bigint_of(ScrJsVal v) { + if (scr_js_is_bigint(v)) return scr_js_bigint(v); + if (scr_js_is_cell(v)) { + const ScrDyn *d = scr_js_cell(v); + if (d && d->kind == SCR_DYN_BIGINT) return d->v.bigint; + } + return NULL; +} + +/* A +1 BigInt result as a jsval, or undefined when the operation threw. */ +static ScrJsVal scr_js_adopt_bigint(ScrBigInt *b) { + return b ? scr_js_of_bigint(b) : scr_js_undefined(); +} +#endif + +ScrJsVal scr_js_add(ScrJsVal left, ScrJsVal right) { + double a, b; + if (scr_js_number(left, &a) && scr_js_number(right, &b)) return scr_js_from_double(a + b); +#if SCR_JS_IMMEDIATES + /* A string with a primitive concatenates without boxing either side: + * ToPrimitive leaves both unchanged, so no user code can run. */ + if (scr_js_string_of(left) || scr_js_string_of(right)) { + ScrStr *ls = scr_js_primitive_string(left); + ScrStr *rs = ls ? scr_js_primitive_string(right) : NULL; + if (ls && rs) { + ScrStr *joined = scr_str_concat(ls, rs); + scr_str_release(ls); + scr_str_release(rs); + return scr_js_of_str(joined); + } + scr_str_release(ls); + } + ScrBigInt *lb = scr_js_bigint_of(left), *rb = scr_js_bigint_of(right); + if (lb && rb) return scr_js_adopt_bigint(scr_bigint_add(lb, rb)); +#endif + ScrDyn *l = scr_js_to_dyn_borrow(left); + ScrDyn *r = scr_js_to_dyn_borrow(right); + ScrDyn *out = scr_dyn_add(l, r); + scr_dyn_release(l); + scr_dyn_release(r); + return scr_js_adopt(out); +} + +/* The number case of scr_dyn_arithmetic: the compiler inlines - * / %, so + * this mostly serves `**` (ECMAScript differs from libm for ±1 ** ±Infinity + * and 1 ** NaN). */ +static double scr_js_number_arithmetic(double a, double b, const ScrStr *operation) { + switch (operation->data[0]) { + case '-': return a - b; + case '/': return a / b; + case '%': return fmod(a, b); + default: + if (operation->len == 2) return (fabs(a) == 1 && !isfinite(b)) ? NAN : pow(a, b); + return a * b; + } +} + +ScrJsVal scr_js_arithmetic(ScrJsVal left, ScrJsVal right, const ScrStr *operation) { + double a, b; + if (scr_js_number(left, &a) && scr_js_number(right, &b)) + return scr_js_from_double(scr_js_number_arithmetic(a, b, operation)); +#if SCR_JS_IMMEDIATES + ScrBigInt *lb = scr_js_bigint_of(left), *rb = scr_js_bigint_of(right); + if (lb && rb) { + switch (operation->data[0]) { + case '-': return scr_js_adopt_bigint(scr_bigint_sub(lb, rb)); + case '/': return scr_js_adopt_bigint(scr_bigint_div(lb, rb)); + case '%': return scr_js_adopt_bigint(scr_bigint_mod(lb, rb)); + default: + return scr_js_adopt_bigint(operation->len == 2 ? scr_bigint_pow(lb, rb) + : scr_bigint_mul(lb, rb)); + } + } +#endif + ScrDyn *l = scr_js_to_dyn_borrow(left); + ScrDyn *r = scr_js_to_dyn_borrow(right); + ScrDyn *out = scr_dyn_arithmetic(l, r, operation); + scr_dyn_release(l); + scr_dyn_release(r); + return scr_js_adopt(out); +} + +ScrJsVal scr_js_bitwise(ScrJsVal left, ScrJsVal right, const ScrStr *operation) { + /* The number case of scr_dyn_bitwise; unary `~` ignores its right + * operand (undefined). */ + double a, b = 0; + bool unary = operation->len == 1 && operation->data[0] == '~'; + if (scr_js_number(left, &a) && (unary || scr_js_number(right, &b))) { + double result; + char op = operation->data[0]; + if (unary) result = scr_bit_not(a); + else if (op == '&') result = scr_bit_and(a, b); + else if (op == '|') result = scr_bit_or(a, b); + else if (op == '^') result = scr_bit_xor(a, b); + else if (op == '<') result = scr_bit_shl(a, b); + else if (operation->len == 2) result = scr_bit_shr(a, b); + else result = scr_bit_ushr(a, b); + return scr_js_from_double(result); + } + ScrDyn *l = scr_js_to_dyn_borrow(left); + ScrDyn *r = scr_js_to_dyn_borrow(right); + ScrDyn *out = scr_dyn_bitwise(l, r, operation); + scr_dyn_release(l); + scr_dyn_release(r); + return scr_js_adopt(out); +} + +bool scr_js_compare(ScrJsVal left, ScrJsVal right, const ScrStr *operation) { + ScrDyn *l = scr_js_to_dyn_borrow(left); + ScrDyn *r = scr_js_to_dyn_borrow(right); + bool out = scr_dyn_compare(l, r, operation); + scr_dyn_release(l); + scr_dyn_release(r); + return out; +} + +ScrJsVal scr_js_to_numeric(ScrJsVal value) { + double n; + if (scr_js_number(value, &n)) return scr_js_from_double(n); + ScrDyn *d = scr_js_to_dyn_borrow(value); + ScrDyn *out = scr_dyn_to_numeric(d); + scr_dyn_release(d); + return scr_js_adopt(out); +} + +ScrJsVal scr_js_increment(ScrJsVal value, bool increment) { + double n; + if (scr_js_number(value, &n)) return scr_js_from_double(n + (increment ? 1 : -1)); + ScrDyn *d = scr_js_to_dyn_borrow(value); + ScrDyn *out = scr_dyn_increment(d, increment); + scr_dyn_release(d); + return scr_js_adopt(out); +} + +double scr_js_to_number(ScrJsVal value) { + double n; + if (scr_js_number(value, &n)) return n; + ScrDyn *d = scr_js_to_dyn_borrow(value); + double out = scr_dyn_number_coerce(d); + scr_dyn_release(d); + return out; +} + +bool scr_js_truthy(ScrJsVal value) { +#if SCR_JS_IMMEDIATES + if (scr_js_is_double(value)) { + double d = scr_js_double(value); + return d == d && d != 0; + } + switch (scr_js_tag(value)) { + case SCR_JS_TAG_CELL: return scr_dyn_truthy(scr_js_cell(value)); + case SCR_JS_TAG_STR: return scr_js_str(value)->len != 0; + case SCR_JS_TAG_BIGINT: return scr_bigint_truthy(scr_js_bigint(value)); + default: return scr_js_bits(value) == SCR_JS_TRUE_BITS; + } +#else + return scr_dyn_truthy(scr_js_cell(value)); +#endif +} diff --git a/packages/runtime/test/headers.test.ts b/packages/runtime/test/headers.test.ts index 4555e658b..fc6ea737e 100644 --- a/packages/runtime/test/headers.test.ts +++ b/packages/runtime/test/headers.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -66,3 +66,14 @@ test("scr_runtime.h holds no declarations of its own", () => { [], ); }); + +// Two-tier invariant I3: the dynamic tier's value representation stays in +// the scr_js_* units. A static runtime unit or module header never includes +// scr_js.h, so no static runtime function can take or return a jsval. +test("only dynamic-tier units include scr_js.h", () => { + const includers = readdirSync(srcDir) + .filter((name) => /\.(c|h)$/.test(name)) + .filter((name) => /^#include "scr_js\.h"/m.test(readFileSync(join(srcDir, name), "utf8"))); + expect(includers.filter((name) => !/^scr_js_\w+\.c$/.test(name))).toEqual([]); + expect(includers).toContain("scr_js_value.c"); +}); diff --git a/tests/corpus/6146-jsval-operators.js b/tests/corpus/6146-jsval-operators.js new file mode 100644 index 000000000..a41b0bd76 --- /dev/null +++ b/tests/corpus/6146-jsval-operators.js @@ -0,0 +1,151 @@ +// Dynamic-tier operators over jsval values: untyped JavaScript computes +// with NaN-boxed immediates, and every result keeps Node's semantics when +// it flows back into objects, arrays, calls and comparisons. +function dyn(value) { + return value; +} + +function show(label, value) { + console.log(label, typeof value, Object.is(value, -0) ? "-0" : String(value)); +} + +function chain(a, b, c) { + return (a * b + c) / (a - c) % 7; +} + +var point = dyn({ x: 3, y: 4.5, z: -0, big: 2n ** 70n, nothing: null, missing: undefined, one: 1 }); +show("chain", chain(point.x, point.y, 1)); +show("chain-float", chain(0.1, 0.2, 0.3)); +show("neg-zero", point.z * 1); +show("neg-zero-sum", point.z + -0); +show("neg-zero-mod", -4 % point.x + 1 - 1 === 0 ? -4 % 2 : 0); +show("nan", point.x * point.missing + 1); +show("inf", point.y / point.z); +show("pow", point.x ** 2 ** 2); +show("pow-edge", point.one ** (point.y / 0)); +show("big", point.big * 3n + 1n); +show("big-div", point.big / 7n); +show("mixed-string", point.x + point.y + "!" + point.x * 2); +show("string-first", "n=" + point.x + point.y); +show("bool", true + point.x + point.nothing); +show("unary", -point.y + +"12" + ~point.x); +show("bitwise", (point.x | 8) ^ (point.y << 2) & 255); +show("shift", -1 >>> point.x); + +// Results stored into dynamic containers and read back. +var bag = dyn({ total: 0, items: [] }); +for (var i = 0; i < 5; i++) { + bag.total = bag.total + i * point.y; + bag.items.push(bag.total % 4, -i * 0); +} +console.log(bag.total, bag.items.join(","), Object.is(bag.items[1], -0)); + +// A local accumulator that only operators write. +function accumulate(o, n) { + var acc = o.start; + for (var k = 0; k < n; k++) acc = acc + o.step * k - 0.5; + return acc; +} +show("acc", accumulate(dyn({ start: 10, step: 0.25 }), 100)); +show("acc-string", accumulate(dyn({ start: "s", step: 2 }), 3)); + +// Strings and BigInts cross into the dynamic tier without a cell. +var texts = dyn({ name: "w", n: 3, big: 10n }); +var label = texts.name; +for (var w = 0; w < 4; w++) label = label + "-" + w + (w % 2 === 0); +console.log(label, texts.name + undefined, texts.name + null, 1 + texts.n + "x"); +console.log(texts.big + 5n, texts.big * -2n, 7n % texts.big, texts.big ** 3n, texts.big - texts.big); +try { + console.log(texts.big / 0n); +} catch (error) { + console.log(error instanceof RangeError, error.message); +} + +// Increments and decrements of dynamic values. +var counters = dyn({ n: "5", b: 9007199254740993n, f: 1.5 }); +counters.n++; +counters.b--; +counters.f += 1; +var post = counters.f++; +console.log(counters.n, counters.b, counters.f, post, typeof counters.n); + +// Comparisons, including NaN and strings. +var cmp = dyn({ a: 2, b: "10", c: "9", nan: NaN }); +console.log(cmp.a < cmp.b, cmp.b < cmp.c, cmp.a >= cmp.nan, cmp.nan <= cmp.nan); +console.log(cmp.a + 1 > cmp.c - 7, cmp.b * 1 <= cmp.c * 1, 3n > cmp.a, "a" < cmp.c); + +// Truthiness of operator results. +var t = dyn({ a: 5, b: 5 }); +if (t.a - t.b) console.log("nonzero"); +else console.log("zero"); +var countdown = t.a; +var seen = []; +while (countdown--) seen.push(countdown); +console.log(seen.join(" "), countdown); +console.log(t.a * 0 || "fallback", (t.a - 5) / 0 ? "nan-truthy" : "nan-falsy"); + +// ToPrimitive order: left before right, valueOf before toString. +var log = []; +function wrap(name, value) { + return dyn({ + valueOf: function () { + log.push(name + ".valueOf"); + return value; + }, + toString: function () { + log.push(name + ".toString"); + return "<" + name + ">"; + }, + }); +} +var l = wrap("l", 3); +var r = wrap("r", 4); +console.log(l * r + l, l + "|" + r, l < r, l - r * 2); +console.log(log.join(" ")); + +// A throw in the middle of a chain leaves no partial state behind. +var bomb = dyn({ + valueOf: function () { + throw new Error("boom"); + }, +}); +try { + var partial = point.x * 2 + bomb * 3; + console.log("unreachable", partial); +} catch (error) { + console.log("caught", error.message); +} +try { + console.log(point.big + point.x); +} catch (error) { + console.log(error instanceof TypeError, error.message); +} +try { + console.log(dyn(Symbol("s")) * point.x); +} catch (error) { + console.log(error instanceof TypeError, error.message); +} + +// A NaN with an arbitrary payload, read from a typed array, stays NaN. +var words = new Uint32Array([0xffffffff, 0xffffffff]); +var odd = dyn({ v: new Float64Array(words.buffer)[0] }); +show("payload-nan", odd.v * 2 + 1); +console.log(odd.v + 1 === odd.v + 1, [odd.v + 1].includes(NaN)); + +// Async functions and generators compute with jsvals across suspensions. +async function asyncSum(o) { + var t = 0; + for (var i = 0; i < 3; i++) t = t + (await o.p) * i + o.q; + return t; +} +function* steps(o) { + var x = o.start; + while (x < o.end) { + yield x * 2; + x = x + o.step; + } +} +asyncSum(dyn({ p: 2, q: 0.25 })).then(function (v) { + show("async", v); +}); +console.log(Array.from(steps(dyn({ start: 1, end: 4, step: 1.5 }))).join(",")); diff --git a/tests/corpus/6147-jsval-any-operators.ts b/tests/corpus/6147-jsval-any-operators.ts new file mode 100644 index 000000000..96fe2695f --- /dev/null +++ b/tests/corpus/6147-jsval-any-operators.ts @@ -0,0 +1,48 @@ +// TypeScript `any` operators compile in the dynamic tier: numbers are jsval +// immediates between operators, and results cross back into typed code and +// dynamic objects exactly as before. +function scale(value: any, factor: any): any { + return value * factor + 1; +} + +function mean(values: any): number { + let total: any = 0; + for (let i = 0; i < values.length; i++) total = total + values[i]; + return total / values.length; +} + +function describe(input: any): string { + const doubled = input + input; + return `${typeof doubled}:${doubled}`; +} + +console.log(scale(20, 2), scale("3", "4"), scale(1.5, -0), scale(true, null)); +console.log(mean([1, 2, 3, 4]), mean(JSON.parse("[0.5, 0.25, 0.125]"))); +console.log(describe(21), describe("ab"), describe(true), describe(null)); + +const stats: any = { hits: 0, misses: 0, ratio: 0 }; +for (let i = 0; i < 1000; i++) { + if (i % 3 === 0) stats.hits = stats.hits + 1; + else stats.misses += 1; +} +stats.ratio = stats.hits / (stats.hits + stats.misses); +console.log(JSON.stringify(stats)); + +let counter: any = JSON.parse("41"); +counter++; +const before: number = counter--; +console.log(counter, before, counter < before, counter * 2 >= 82); + +const weird: any = { valueOf: () => 7 }; +const fromObject: number = weird * 6; +console.log(fromObject, weird + 1, weird > 6); + +function guarded(x: any): string { + try { + const n: number = x - 1; + return `ok ${n}`; + } catch (error) { + return `threw ${(error as Error).message}`; + } +} +console.log(guarded(5), guarded("5"), guarded(5n)); diff --git a/tests/fixtures/self-hosting/class-types.ts b/tests/fixtures/self-hosting/class-types.ts index 2a66e7444..c43b43f56 100644 --- a/tests/fixtures/self-hosting/class-types.ts +++ b/tests/fixtures/self-hosting/class-types.ts @@ -8,7 +8,7 @@ const kept: IrType = { kind: "object", className: "Registered" }; const expr: IrExpr = { kind: "varRef", localId: "x", type: missing, loc }; const nested: IrType = { kind: "func", params: [{ kind: "array", elem: missing }], ret: { kind: "generator", yieldT: missing, retT: kept, nextT: missing } }; -const module: IrModule = { irVersion: 16, sourceFile: loc.file, entry: "main", +const module: IrModule = { irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [{ localId: "x", name: "x", type: missing }], returnType: nested, locals: [], loc, body: [{ kind: "exprStmt", expr, loc }] }], records: [{ id: "r", fields: [{ name: "callback", type: nested }], indexValue: missing }], diff --git a/tests/harness/self-hosting-validator-cases.ts b/tests/harness/self-hosting-validator-cases.ts index 4a02c9d58..cbebae409 100644 --- a/tests/harness/self-hosting-validator-cases.ts +++ b/tests/harness/self-hosting-validator-cases.ts @@ -16,7 +16,7 @@ import { boolLit, numLit, strLit, varRef } from "../../packages/compiler/src/ir/ const loc = { file: "validator-input.ts", start: 12, end: 34 }; const expression = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc }); const base = (): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: loc.file, entry: "main", functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }], From 5e964965f6abef96a36fe142e6780f7b81da4dfd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:19:59 -0700 Subject: [PATCH 009/127] Assign process.emitWarning pi's CLI setup replaces process.emitWarning to silence warnings. The assignment now stores the replacement: later process.emitWarning(...) calls apply it to their arguments, and runtime-built warnings (Node's own modules call process.emitWarning) reach it as (message, type[, code]) instead of the report and the 'warning' listeners. process.title writes already lowered; the corpus program pins both. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-stmts.ts | 26 +++++++++ .../compiler/src/ir/builtin-signatures.ts | 1 + packages/compiler/src/ir/runtime-features.ts | 1 + .../6231-process-emit-warning-assign.ts.json | 6 ++ packages/runtime/src/scr_async.h | 6 ++ packages/runtime/src/scr_async_dyn.c | 58 +++++++++++++++++++ .../6231-process-emit-warning-assign.ts | 35 +++++++++++ 8 files changed, 134 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json create mode 100644 tests/corpus/6231-process-emit-warning-assign.ts diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02bf37192..5f5a1223e 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1306,6 +1306,7 @@ export const LIB_FN_SYMS: Record = { "process.emitWarningText": "scr_process_emit_warning_text", "process.deprecationFlag": "scr_process_deprecation_flag", "process.setDeprecationFlag": "scr_process_set_deprecation_flag", + "process.setEmitWarning": "scr_process_set_emit_warning", "process.onUncaughtException": "scr_process_on_uncaught_exception", "process.offUncaughtException": "scr_process_off_uncaught_exception", "process.onUnhandledRejection": "scr_process_on_unhandled_rejection", diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 0f1b606ef..b7f0c2481 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -6758,6 +6758,32 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm loc, }; } + // process.emitWarning = fn: an ordinary writable property. Later + // process.emitWarning(...) calls, and the warnings Node's own + // modules emit through process.emitWarning, call the replacement + // instead (silencing them when it does nothing). + if (flag === "emitWarning") { + const loc = locOf(expr); + let rhs: ts.Expression = expr.right; + while ( + ts.isParenthesizedExpression(rhs) || + ts.isAsExpression(rhs) || + ts.isSatisfiesExpression(rhs) + ) + rhs = rhs.expression; + const value = lowerer.lowerExprExpecting(rhs, DYN); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "process.setEmitWarning", + args: [value], + type: VOID, + loc, + }, + loc, + }; + } // process.title = name: libuv's uv_set_process_title (the // runtime truncates to the original argument area on POSIX). if (flag === "title") { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 08c493ae4..539bd5eb8 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4112,6 +4112,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * warnings and DeprecationWarning emissions read their truthiness. */ "process.deprecationFlag": { argTypes: [F64], result: DYN }, "process.setDeprecationFlag": { argTypes: [F64, DYN], result: VOID }, + "process.setEmitWarning": { argTypes: [DYN], result: VOID }, /** AsyncLocalStorage (node:async_hooks — scr_async.c): stores are f64 * handles; contexts are immutable fiber-carried snapshots (spawned * fibers inherit the spawner's — Node's init-time capture). run/exit diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index d16edfabd..72f30a76e 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -67,6 +67,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "process.emitWarningText", "process.deprecationFlag", "process.setDeprecationFlag", + "process.setEmitWarning", // The performance timeline (scr_perf.c, linked with this unit for its // MaxPerformanceEntryBufferExceededWarning). "perf.mark", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json new file mode 100644 index 000000000..ac7be42ac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6231-process-emit-warning-assign.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index 7f294ca29..e591c7655 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -212,6 +212,12 @@ void scr_process_on_warning(ScrDyn *fn); void scr_process_off_warning(ScrDyn *fn); void scr_process_emit_warning(ScrDyn *args); void scr_emit_warning(const char *name, const char *code, ScrStr *message); +/* process.emitWarning = fn (borrowed; the runtime keeps its own + * reference). Later process.emitWarning(...) calls apply the replacement + * to their arguments, and runtime-built warnings (Node's own modules call + * process.emitWarning) reach it as (message, type[, code]) instead of the + * report and the 'warning' listeners. */ +void scr_process_set_emit_warning(ScrDyn *fn); /* scr_emit_warning once a 'warning' listener exists (scr_lib.c; set by the * listener registration): units that cannot link the dyn-async unit route * their warnings through it, and print the default report otherwise. */ diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index b7387c13e..b7e4539c0 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -860,10 +860,53 @@ ScrDyn *scr_warning_new(ScrStr *message, const char *type, const ScrStr *code) { return w; } +/* The program's process.emitWarning replacement, or NULL while the + * property holds Node's own function. */ +static SCR_TL ScrDyn *scr_emit_warning_override = NULL; + +static void scr_emit_warning_override_teardown(void) { + scr_dyn_release(scr_emit_warning_override); + scr_emit_warning_override = NULL; +} + +void scr_process_set_emit_warning(ScrDyn *fn) { + if (scr_emit_warning_override == NULL) scr_atexit(scr_emit_warning_override_teardown); + ScrDyn *previous = scr_emit_warning_override; + scr_emit_warning_override = scr_dyn_retain(fn); + scr_dyn_release(previous); + /* Units that report their own warnings without a listener route them + * here once a replacement exists. */ + scr_warning_emit_hook = scr_emit_warning; +} + +/* A runtime-built warning while a replacement is installed: Node's modules + * call process.emitWarning(message, type, code), so the replacement sees + * those arguments; nothing is reported. A throw stays pending. */ +static void scr_emit_warning_replaced(ScrStr *message, ScrStr *type, ScrStr *code) { + ScrDyn *args[3]; + size_t argc = 0; + args[argc++] = scr_dyn_new_str(message); + ScrStr *fallback = type ? NULL : scr_str_new("Warning", 7); + args[argc++] = scr_dyn_new_str(type ? type : fallback); + scr_str_release(fallback); + if (code) args[argc++] = scr_dyn_new_str(code); + ScrDyn *r = scr_dyn_call(scr_emit_warning_override, args, argc, "process.emitWarning"); + scr_dyn_release(r); + for (size_t i = 0; i < argc; i++) scr_dyn_release(args[i]); +} + /* A warning built from C parts (the runtime deprecation sites): name * defaults to "Warning"; code/detail optional. Reported synchronously. * Borrows message. */ void scr_emit_warning(const char *name, const char *code, ScrStr *message) { + if (scr_emit_warning_override != NULL) { + ScrStr *type_text = scr_str_new(name ? name : "Warning", strlen(name ? name : "Warning")); + ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; + scr_emit_warning_replaced(message, type_text, code_text); + scr_str_release(type_text); + scr_str_release(code_text); + return; + } ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; ScrDyn *w = scr_warning_new(message, name ? name : "Warning", code_text); scr_str_release(code_text); @@ -894,6 +937,16 @@ static void scr_warning_defer(ScrDyn *w, void (*fire)(ScrClosure *)) { } void scr_warning_emit_deferred(ScrDyn *w) { + if (scr_emit_warning_override != NULL) { + const ScrDyn *em = scr_dyn_obj_get(w, "message", 7); + const ScrDyn *ec = scr_dyn_obj_get(w, "code", 4); + ScrStr *name = scr_warning_name(w); + ScrStr *message = em && em->kind == SCR_DYN_STR ? scr_str_retain(em->v.str) : scr_str_new("", 0); + scr_emit_warning_replaced(message, name, ec && ec->kind == SCR_DYN_STR ? ec->v.str : NULL); + scr_str_release(message); + scr_str_release(name); + return; + } if (scr_warning_is_deprecation(w)) { if (scr_process_deprecation_flag_on(0)) return; if (scr_process_deprecation_flag_on(1)) { @@ -959,6 +1012,11 @@ static void scr_warn_bad_arg(const char *arg, const char *want) { * ERR_INVALID_ARG_TYPE TypeErrors at the call; non-string details are * ignored. The warning itself is emitted on the next tick. */ void scr_process_emit_warning(ScrDyn *args) { + if (scr_emit_warning_override != NULL) { + ScrDyn *r = scr_dyn_apply(scr_emit_warning_override, args, "process.emitWarning"); + scr_dyn_release(r); + return; + } size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; ScrDyn *const *items = args->kind == SCR_DYN_ARR ? args->v.arr.items : NULL; ScrDyn *warning = argc >= 1 ? items[0] : NULL; diff --git a/tests/corpus/6231-process-emit-warning-assign.ts b/tests/corpus/6231-process-emit-warning-assign.ts new file mode 100644 index 000000000..1cae44f82 --- /dev/null +++ b/tests/corpus/6231-process-emit-warning-assign.ts @@ -0,0 +1,35 @@ +// process.emitWarning and process.title are ordinary writable properties +// (pi's CLI setup assigns both). After the replacement, process.emitWarning +// calls reach it with their own arguments, no report reaches stderr, and +// the warnings Node's own modules emit through process.emitWarning (a +// util.deprecate wrapper's first call) reach it too. +import { deprecate } from "node:util"; + +process.title = "pi"; +console.log("title", process.title); +process.env.PI_CODING_AGENT = "true"; +console.log("env", process.env.PI_CODING_AGENT); + +const seen: string[] = []; +process.on("warning", (w) => seen.push(`listener ${w.name}`)); +function show(a: unknown): string { + if (a instanceof Error) return `Error(${a.message})`; + return typeof a === "object" ? JSON.stringify(a) : String(a); +} +process.emitWarning = ((warning: unknown, type: unknown, code: unknown) => { + seen.push(`${show(warning)}|${show(type)}|${show(code)}`); +}) as typeof process.emitWarning; + +process.emitWarning("plain"); +process.emitWarning("typed", "CustomWarning", "CW_1"); +process.emitWarning("options", { type: "OptWarning", code: "OW_1", detail: "d" }); +process.emitWarning(new Error("as error")); +const old = deprecate(() => "result", "old() is deprecated", "DEP_SCRIPTC_1"); +console.log("deprecated call", old()); +console.log("second call", old()); +console.log("sync", seen.join(" ; ")); + +// A replacement that does nothing silences warnings entirely. +process.emitWarning = (() => {}) as typeof process.emitWarning; +process.emitWarning("silenced"); +setTimeout(() => console.log("after tick", seen.length), 0); From 8a02efaa91ef8aea22014ab8067e5c67388863ce Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:28:55 -0700 Subject: [PATCH 010/127] Type process.getBuiltinModule results as module namespaces Under @types/node, process.getBuiltinModule("node:os") types as the module namespace, which lowered as an unchecked dynamic value and failed. A string-literal id naming a supported builtin now lowers to the namespace value an `import * as` binding has, including pi's cast-and-probe spelling `(process as P).getBuiltinModule?.("node:os") ?? null`; a nullable namespace binding narrowed present keys the same member tables, and a namespace is truthy. process.features read as a whole value (`"sea" in process.features`) is an object holding none of the flags, matching the existing member reads. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 2 + .../src/frontend/lowering/builtins/process.ts | 39 +++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 14 ++++++- .../compiler/src/frontend/lowering/lowerer.ts | 12 ++++++ .../main.ts.json | 6 +++ .../6232-process-builtin-module-typed/main.ts | 28 +++++++++++++ .../package.json | 1 + .../tsconfig.json | 7 ++++ 8 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json create mode 100644 tests/corpus/6232-process-builtin-module-typed/main.ts create mode 100644 tests/corpus/6232-process-builtin-module-typed/package.json create mode 100644 tests/corpus/6232-process-builtin-module-typed/tsconfig.json diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 26c3b840e..55566f2a6 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5756,6 +5756,8 @@ export class LlEmitter { // Process streams use a numeric descriptor internally, including zero // for stdin. They remain JavaScript objects in boolean contexts. case "procStream": + // A module namespace object is always present. + case "moduleNs": return "true"; case "array": case "record": diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 94c2cdba7..777ec50e7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -26,6 +26,7 @@ import { funcOf, } from "../../../ir/ir.js"; import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js"; +import { canonicalBuiltinModule } from "../../builtin-modules.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js"; @@ -364,6 +365,11 @@ export function lowerProcessProperty( if (member === "versions") { return { kind: "libCall", fn: "process.versions", args: [], type: DYN, loc }; } + // process.features as a value (`"sea" in process.features`): an object + // holding none of the flags, consistent with the member reads above. + if (member === "features") { + return { kind: "dynObjLit", type: DYN, loc }; + } // process.noDeprecation / process.throwDeprecation: the values the // program assigned (undefined before that — no Node flags reach a // compiled binary). @@ -1521,3 +1527,36 @@ function processCredentialCall(member: string, loc: SrcLoc): IrExpr { loc, }; } + +/** `process.getBuiltinModule("node:os")`: a string-literal id naming a + * supported builtin whose call the checker types as that module's + * namespace (@types/node's BuiltInModule map) answers the namespace value + * `import * as ns` binds, so its members key the builtin tables at their + * use sites. The receiver may be cast (`(process as P)`) and the call + * optional: Node 24's process always has the member, and the cast changes + * no runtime value. Other ids keep the dynamic loader (undefined for + * unknown ids, Node's argument errors). */ +export function lowerProcessBuiltinModuleNamespace( + lowerer: Lowerer, + call: ts.CallExpression, +): IrExpr | null { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "getBuiltinModule") + return null; + const id = call.arguments[0]; + if (call.arguments.length !== 1 || !id || !ts.isStringLiteralLike(id)) return null; + let receiver: ts.Expression = callee.expression; + while ( + ts.isParenthesizedExpression(receiver) || + ts.isAsExpression(receiver) || + ts.isSatisfiesExpression(receiver) || + ts.isNonNullExpression(receiver) + ) + receiver = receiver.expression; + if (!lowerer.isStdlibGlobal(receiver, "process")) return null; + const module = canonicalBuiltinModule(id.text); + if (module === null) return null; + const type = lowerer.mapTypeOf(lowerer.checker.getNonNullableType(lowerer.typeOf(call))); + if (type?.kind !== "moduleNs" || type.moduleId !== `builtin:${module}`) return null; + return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc: locOf(call) }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..cebad8053 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -126,7 +126,10 @@ import { lowerPerfHooksCall, perfEntryTag, } from "./builtins/performance.js"; -import { lowerProcStreamMethodCall } from "./builtins/process.js"; +import { + lowerProcStreamMethodCall, + lowerProcessBuiltinModuleNamespace, +} from "./builtins/process.js"; import { recordMayKeepSource, throughRecordSource } from "./record-sources.js"; import { lowerReflectCall } from "./builtins/reflection.js"; import { @@ -363,6 +366,15 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { return { kind: "libCall", fn: "http2.streamNoop", args: [], type: VOID, loc }; } + // `process.getBuiltinModule("node:os")` typed as the module namespace, + // including pi's cast-and-probe spelling + // `(process as P).getBuiltinModule?.("node:os")` (the member always + // exists on Node 24's process object). + { + const namespace = lowerProcessBuiltinModuleNamespace(lowerer, expr); + if (namespace) return namespace; + } + // Optional-chain call forms: `f?.()` (the token on the call) and // `a?.m()` (the token on the member access). The handled markers keep // the chain lowering's re-entrant dispatch from looping. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 94d526831..697569bc2 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11569,6 +11569,18 @@ export class Lowerer { if (mapped?.kind === "moduleNs" && mapped.moduleId.startsWith("builtin:")) { return mapped.moduleId.slice("builtin:".length); } + // A nullable namespace binding (`const os: typeof NodeOs | null`, a + // probed process.getBuiltinModule result) read where the checker + // narrowed it present: its members key the same tables. + if (stored?.kind === "union") { + const present = this.unions.get(stored.unionId)?.arms.filter((arm) => !isUnitType(arm)); + const only = present?.length === 1 ? present[0] : undefined; + if (only?.kind === "moduleNs" && only.moduleId.startsWith("builtin:")) { + const narrowed = this.mapTypeOf(this.typeOf(expr)); + if (narrowed?.kind === "moduleNs" && narrowed.moduleId === only.moduleId) + return only.moduleId.slice("builtin:".length); + } + } if (ts.isIdentifier(expr)) { const symbol = this.checker.getSymbolAtLocation(expr); const decl = symbol ? this.checker.declarationsOf(symbol)[0] : undefined; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json new file mode 100644 index 000000000..8adab38b7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6232-process-builtin-module-typed/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6232-process-builtin-module-typed/main.ts b/tests/corpus/6232-process-builtin-module-typed/main.ts new file mode 100644 index 000000000..7765bd228 --- /dev/null +++ b/tests/corpus/6232-process-builtin-module-typed/main.ts @@ -0,0 +1,28 @@ +// process.getBuiltinModule and process.features under @types/node: a +// literal id types as the module namespace, so the result is the same +// object `import * as os` binds (pi's user-agent probe casts process and +// calls the member optionally), and process.features is an object a +// feature probe can test with `in` (pi's single-executable check). +import * as os from "node:os"; +import type * as NodeOs from "node:os"; + +const direct = process.getBuiltinModule("node:os"); +console.log("direct", direct.platform() === os.platform(), direct.EOL === os.EOL); +console.log("bare id", process.getBuiltinModule("os").arch() === os.arch()); + +type ProcessWithOsBuiltinModule = typeof process & { + getBuiltinModule?: (id: "node:os") => typeof NodeOs; +}; +function loadNodeOs(): typeof NodeOs | null { + if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { + return null; + } + return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; +} +const nodeOs = loadNodeOs(); +const agent = nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; +console.log("agent", agent === `pi (${os.platform()} ${os.release()}; ${os.arch()})`); +console.log("present", nodeOs !== null, nodeOs ? typeof nodeOs.homedir() : "none"); + +const isNodeSeaBinary = "sea" in process.features && process.features.sea === true; +console.log("sea", isNodeSeaBinary, "sea" in process.features); diff --git a/tests/corpus/6232-process-builtin-module-typed/package.json b/tests/corpus/6232-process-builtin-module-typed/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/6232-process-builtin-module-typed/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/6232-process-builtin-module-typed/tsconfig.json b/tests/corpus/6232-process-builtin-module-typed/tsconfig.json new file mode 100644 index 000000000..ae4eda194 --- /dev/null +++ b/tests/corpus/6232-process-builtin-module-typed/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true + } +} From 0bb9b750fe8b0e9609a18bd44a47008de8481f1b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:39:22 -0700 Subject: [PATCH 011/127] Iterate class instances whose async iterator is an async generator pi-ai consumes every provider stream with `for await` over an EventStream whose `async *[Symbol.asyncIterator](): AsyncIterator` is an async generator method; that was SC1070. The loop now opens the iterator with the method call and drives the returned async generator like any other (break and throw close it). An `async *` declaration annotated with the protocol interfaces (AsyncIterator, AsyncIterableIterator, AsyncIterable) maps like AsyncGenerator. Boxing a class instance whose fields hold a promise now links the dynamic async unit (the materializer boxes the promise), which EventStream's own `this.isComplete(event)` calls need. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/function-returns.ts | 7 + .../lowering/lower-async-iteration.ts | 39 +++- .../src/frontend/lowering/lower-stmts.ts | 6 +- packages/compiler/src/frontend/type-mapper.ts | 26 +++ packages/compiler/src/ir/runtime-features.ts | 30 +++- ...33-for-await-class-async-generator.ts.json | 6 + .../6233-for-await-class-async-generator.ts | 168 ++++++++++++++++++ 7 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json create mode 100644 tests/corpus/6233-for-await-class-async-generator.ts diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 5ba63be26..e72504c3d 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -1,3 +1,4 @@ +import { mapAsyncGeneratorAnnotation } from "../type-mapper.js"; import { dynUndefinedExpr } from "../../ir/build.js"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; @@ -146,6 +147,12 @@ export function declaredReturnType( // (`.action(() => { throw ... })`). `never` VALUES stay unmapped. if (retTsType.flags & ts.TypeFlags.Never) return VOID; let mappedReturn = lowerer.mapTypeOf(retTsType); + if ( + mappedReturn === null && + decl.asteriskToken && + decl.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ) + mappedReturn = mapAsyncGeneratorAnnotation(retTsType, lowerer.typeCtx); if ( isJsSourceFile(decl.getSourceFile()) && decl.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) && diff --git a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts index 1bd074dd7..053d94201 100644 --- a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts +++ b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts @@ -20,7 +20,7 @@ import { VOID, } from "../../ir/ir.js"; import { locOf } from "../program.js"; -import { extractIteratorValue } from "./lower-generators.js"; +import { extractIteratorValue, type GenType } from "./lower-generators.js"; import { streamSidesOf } from "./lower-stream.js"; import { staticReadableStreamElementType } from "./lower-stdlib.js"; import { genResultRecord } from "../type-mapper.js"; @@ -282,6 +282,43 @@ function classAsyncIteratorPlan( }; } +/** `for await (const x of obj)` where obj's class declares (or inherits) + * `async *[Symbol.asyncIterator]()`: the loop opens the iterator with that + * method call, evaluated once, and then drives the async generator it + * returns exactly like `for await (const x of obj[Symbol.asyncIterator]())` + * (pi's EventStream). Null for other sources. */ +export function classAsyncGeneratorIterator( + lowerer: Lowerer, + expression: ts.Expression, +): (IrExpr & { type: GenType }) | null { + const sourceType = lowerer.mapTypeOf(lowerer.typeOf(expression)); + if (sourceType?.kind !== "object") return null; + const info = lowerer.classes.get(sourceType.className); + const open = info ? lowerer.findMethodOn(info, "sym:asyncIterator") : null; + if ( + !open || + open.sig.abstract === true || + open.sig.params.length !== 0 || + open.sig.ret.kind !== "generator" || + !open.sig.ret.async + ) + return null; + const retT = open.sig.ret; + const source = lowerer.lowerExpr(expression); + if (source.type.kind !== "object") return null; + return { + ...lowerer.accessorCall( + sourceType.className, + "sym:asyncIterator", + source, + [], + retT, + locOf(expression), + ), + type: retT, + }; +} + export function checkForAwaitBinding( lowerer: Lowerer, stmt: ts.ForOfStatement, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index b7f0c2481..784036fdd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -28,7 +28,7 @@ import { lowerYieldStarStatement, type GenType, } from "./lower-generators.js"; -import { lowerForAwaitBuiltin } from "./lower-async-iteration.js"; +import { classAsyncGeneratorIterator, lowerForAwaitBuiltin } from "./lower-async-iteration.js"; import { BOOL, BYTES_U8, @@ -8281,6 +8281,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { } } } + { + const opened = classAsyncGeneratorIterator(lowerer, stmt.expression); + if (opened) return lowerForAwaitGenerator(lowerer, stmt, opened, labels); + } if ( ts.isPropertyAccessExpression(stmt.expression) && lowerer.stdlibGlobalMember(stmt.expression, "process") === "stdin" diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index f37703fd8..191ad670d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1313,6 +1313,32 @@ export function isWorkerHandleType( return handle; } +/** The return annotation of an `async *` declaration spelled with the + * protocol interfaces the generator object implements — + * `AsyncIterator`, `AsyncIterableIterator` or `AsyncIterable` (pi's + * `async *[Symbol.asyncIterator](): AsyncIterator`): the body always + * produces an async generator, so the declaration maps exactly like an + * `AsyncGenerator` annotation. Values of these types + * elsewhere stay unmapped (they can hold any object implementing the + * protocol). Null when the annotation is not one of them. */ +export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const { checker } = ctx; + const symbol = type.getSymbol(); + if ( + !symbol || + !["AsyncIterator", "AsyncIterableIterator", "AsyncIterable"].includes(symbol.name) || + !checker + .declarationsOf(symbol) + .some((d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())) + ) + return null; + const args = checker.getTypeArguments(type as ts.TypeReference); + const channels = genChannels(args[0], args[1], args[2], ctx); + if (!channels) return null; + if (!genResultRecord(channels.yieldT, channels.retT, ctx.shapes, ctx.unions)) return null; + return { kind: "generator", async: true, ...channels }; +} + export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (mapTypeDepth >= MAP_TYPE_MAX_DEPTH) return null; // These intrinsic domains cannot depend on generic bindings, declaration diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 72f30a76e..d6eb1b1cb 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1,4 +1,4 @@ -import type { IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } from "./ir.js"; +import type { IrClassDef, IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } from "./ir.js"; import type { IrLibFn } from "./builtin-signatures.js"; import { isDirentShape, @@ -137,6 +137,7 @@ export function moduleRuntimeFeatures(mod: IrModule): RuntimeFeatures { interface TypeTables { records: Map; unions: Map; + classes: Map; } /** Whether boxing a value of type `t` as dyn boxes a promise, which goes @@ -167,6 +168,32 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set): boolean const def = tables.unions.get(t.unionId); return def?.arms.some((a) => boxesPromise(a, tables, seen)) ?? false; } + case "object": { + // A boxed class instance materializes its fields, inherited ones + // included, and the instance can be any subclass. + const key = `class:${t.className}`; + if (seen.has(key)) return false; + seen.add(key); + const related = (def: IrClassDef): boolean => { + for ( + let c: IrClassDef | undefined = def; + c; + c = c.base ? tables.classes.get(c.base) : undefined + ) + if (c.name === t.className) return true; + return false; + }; + for (const def of tables.classes.values()) { + if (!related(def)) continue; + for ( + let c: IrClassDef | undefined = def; + c; + c = c.base ? tables.classes.get(c.base) : undefined + ) + if (c.fields.some((f) => boxesPromise(f.type, tables, seen))) return true; + } + return false; + } default: return false; } @@ -223,6 +250,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run (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") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json new file mode 100644 index 000000000..89f131b0f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6233-for-await-class-async-generator.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6233-for-await-class-async-generator.ts b/tests/corpus/6233-for-await-class-async-generator.ts new file mode 100644 index 000000000..1a88df7e8 --- /dev/null +++ b/tests/corpus/6233-for-await-class-async-generator.ts @@ -0,0 +1,168 @@ +// `for await` over a class instance whose [Symbol.asyncIterator] is an +// async generator method — pi-ai's EventStream, which is how every +// provider stream is consumed: a generic push/end queue, a subclass, the +// protocol-interface return annotations, producers that run between +// iterations, early exit (the generator's finally runs), and errors. +class FifoQueue { + private incoming: T[] = []; + private outgoing: T[] = []; + + get length(): number { + return this.incoming.length + this.outgoing.length; + } + + enqueue(value: T): void { + this.incoming.push(value); + } + + dequeue(): T | undefined { + if (this.outgoing.length === 0) { + while (this.incoming.length > 0) { + this.outgoing.push(this.incoming.pop()!); + } + } + return this.outgoing.pop(); + } +} + +class EventStream implements AsyncIterable { + private queue = new FifoQueue(); + private waiting = new FifoQueue<(value: IteratorResult) => void>(); + protected done = false; + private finalResultPromise: Promise; + private resolveFinalResult!: (result: R) => void; + private isComplete: (event: T) => boolean; + private extractResult: (event: T) => R; + + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + + push(event: T): void { + if (this.done) return; + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + const waiter = this.waiting.dequeue(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.enqueue(event); + } + } + + end(result?: R): void { + this.done = true; + if (result !== undefined) { + this.resolveFinalResult(result); + } + while (this.waiting.length > 0) { + const waiter = this.waiting.dequeue()!; + waiter({ value: undefined as any, done: true }); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.queue.length > 0) { + yield this.queue.dequeue()!; + } else if (this.done) { + return; + } else { + const result = await new Promise>((resolve) => this.waiting.enqueue(resolve)); + if (result.done) return; + yield result.value; + } + } + } + + result(): Promise { + return this.finalResultPromise; + } +} + +type Event = { type: "text"; delta: string } | { type: "done"; text: string }; + +class TextStream extends EventStream { + constructor() { + super( + (event) => event.type === "done", + (event) => (event.type === "done" ? event.text : ""), + ); + } +} + +function produce(stream: TextStream, words: string[]): void { + let i = 0; + const step = (): void => { + if (i < words.length) { + stream.push({ type: "text", delta: words[i]! }); + i++; + setTimeout(step, 1); + } else { + stream.push({ type: "done", text: words.join("") }); + } + }; + step(); +} + +class Ticker { + log: string[] = []; + async *[Symbol.asyncIterator](): AsyncIterableIterator { + try { + for (let n = 1; n <= 5; n++) { + this.log.push(`yield ${n}`); + yield n; + } + } finally { + this.log.push("closed"); + } + } +} + +class Failing { + async *[Symbol.asyncIterator](): AsyncGenerator { + yield "first"; + throw new Error("stream failed"); + } +} + +async function main(): Promise { + const stream = new TextStream(); + produce(stream, ["Hello", " from", " the", " stub"]); + let text = ""; + for await (const event of stream) { + if (event.type === "text") text += event.delta; + else console.log("done event", event.text); + } + console.log("streamed", JSON.stringify(text), "result", await stream.result()); + + // Events queued before iteration starts, then end() with no consumer waiting. + const early = new TextStream(); + early.push({ type: "text", delta: "a" }); + early.push({ type: "text", delta: "b" }); + early.end("ab"); + const seen: string[] = []; + for await (const event of early) seen.push(event.type === "text" ? event.delta : "?"); + console.log("queued", seen.join(","), await early.result()); + + // break closes the generator: its finally runs before the loop exits. + const ticker = new Ticker(); + for await (const n of ticker) { + if (n === 3) break; + } + console.log("ticker", ticker.log.join(" | ")); + + // A throw from the generator rejects the loop. + try { + for await (const s of new Failing()) console.log("got", s); + } catch (e) { + console.log("caught", (e as Error).message); + } +} +main(); From 71b99913a24b1cf3747b15c738eeec2e35b3b936 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:45:07 -0700 Subject: [PATCH 012/127] Allow WeakMap- and WeakSet-typed class fields Weak collections already lower to checked-dynamic storage as locals, but a class field of those types was fenced as a dynamic-typed field (pi's AgentSession keys entry ids by message in a WeakMap field). Declared, optional, and constructor-assigned fields of those types now store the native collection like the locals do. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 30 ++++++++++++-- .../6234-weak-collection-class-fields.ts.json | 6 +++ .../6234-weak-collection-class-fields.ts | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json create mode 100644 tests/corpus/6234-weak-collection-class-fields.ts diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 697569bc2..5804502b7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3002,13 +3002,37 @@ export class Lowerer { /** Declared class fields may store a DYN value only when its static type * is `unknown` or `object` (also with `null` or `undefined`, or an array - * of those), names checked protocol identity, or is an owned runtime - * handle (a Worker or MessagePort); `any` fields stay refused. */ + * of those), names checked protocol identity, is an owned runtime + * handle (a Worker or MessagePort), or is a WeakMap/WeakSet (native weak + * collections live in checked-dynamic storage, as locals of those types + * do); `any` fields stay refused. */ classFieldStoresDyn(type: ts.Type): boolean { return ( this.opaqueValueType(type) || this.classImplementedProtocol(type) || - isWorkerHandleType(type, this.typeCtx) + isWorkerHandleType(type, this.typeCtx) || + this.weakCollectionType(type) + ); + } + + /** A stdlib WeakMap or WeakSet, alone or with `null`/`undefined`. */ + private weakCollectionType(type: ts.Type): boolean { + const weak = (part: ts.Type): boolean => { + const symbol = part.getSymbol(); + return ( + (symbol?.name === "WeakMap" || symbol?.name === "WeakSet") && + this.checker + .declarationsOf(symbol) + .some((d) => ts.isInterfaceDeclaration(d) && this.isStdlibFile(d.getSourceFile())) + ); + }; + if (!type.isUnionType()) return weak(type); + const parts = ts.constituentTypes(type); + return ( + parts.some(weak) && + parts.every( + (part) => (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0 || weak(part), + ) ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json new file mode 100644 index 000000000..e7e18883a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6234-weak-collection-class-fields.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6234-weak-collection-class-fields.ts b/tests/corpus/6234-weak-collection-class-fields.ts new file mode 100644 index 000000000..d7a085921 --- /dev/null +++ b/tests/corpus/6234-weak-collection-class-fields.ts @@ -0,0 +1,39 @@ +// WeakMap- and WeakSet-typed class fields (pi's AgentSession keys entry ids by +// message object): initialized, optional, and constructor-assigned fields hold +// the native weak collections like locals of those types. +class Session { + private readonly _entryIdsByMessage = new WeakMap(); + private seen = new WeakSet(); + cache: WeakMap | undefined; + record(message: object, id: string): void { + this._entryIdsByMessage.set(message, id); + this.seen.add(message); + } + idOf(message: object): string | undefined { + return this._entryIdsByMessage.get(message); + } + has(message: object): boolean { + return this.seen.has(message) && this._entryIdsByMessage.has(message); + } +} +const s = new Session(); +const a = { role: "user" }; +const b = { role: "assistant" }; +s.record(a, "e1"); +console.log(s.idOf(a), s.idOf(b), s.has(a), s.has(b)); +s.cache = new WeakMap(); +s.cache.set(a, 1); +console.log(s.cache.get(a), s.cache.has(b), s.cache instanceof WeakMap); +class Registry { + private readonly names: WeakMap; + constructor(names: WeakMap) { + this.names = names; + } + name(o: object): string { + return this.names.get(o) ?? "(unnamed)"; + } +} +const names = new WeakMap(); +names.set(a, "alice"); +const registry = new Registry(names); +console.log(registry.name(a), registry.name(b)); From bcc35ddf1a29adbe62950483fa0fb477813cdf70 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:52:59 -0700 Subject: [PATCH 013/127] Call chalk.bold from TypeScript #1309 let a checker-untyped receiver call a member named like a String.prototype-only method (`bold`, `italics`, ...), refusing only a string receiver at run time. TypeScript importing chalk's JavaScript source types the default export as a plain function, so `chalk.bold` is an `any` member on a typed receiver and still fenced; pi calls it in nine places. A checker-untyped member now takes the same path. The chalk 5 fixture pins root, aliased, and Chalk-instance `bold` calls. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lower-calls.ts | 8 +++++++- tests/fixtures/npm/cases/chalk5/main.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index cebad8053..44b08dbb0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3497,8 +3497,14 @@ function lowerDynReceiverMethodCall( // own or inherited member exactly like a non-prototype name (chalk's // `chalk.red.bold(...)` reads a prototype getter of a function), so // only a string receiver keeps the refusal, deferred to run time. + // The same holds when only the MEMBER is checker-untyped: TypeScript + // importing chalk's JavaScript source types the default export as a + // plain function, so `chalk.bold` reads as `any` on a typed receiver. const stringOnlyName = - checkerUntyped && !ownCallable && DYN_STRING_PROTO_ONLY_NAMES.has(access.name.text); + (checkerUntyped || + (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && + !ownCallable && + DYN_STRING_PROTO_ONLY_NAMES.has(access.name.text); if (DYN_PROTO_METHOD_NAMES.has(access.name.text) && !ownCallable && !stringOnlyName) return null; // Optional forms (`obj.cb?.()`, `obj?.cb()`) belong to the chain // machinery's short-circuit semantics — not modeled here yet. diff --git a/tests/fixtures/npm/cases/chalk5/main.ts b/tests/fixtures/npm/cases/chalk5/main.ts index 6a33a1466..0bfa806c1 100644 --- a/tests/fixtures/npm/cases/chalk5/main.ts +++ b/tests/fixtures/npm/cases/chalk5/main.ts @@ -15,6 +15,13 @@ console.log(modifierNames.join(",")); console.log(foregroundColorNames.length + " " + backgroundColorNames.length + " " + colorNames.length); show("red", chalk.red("red")); +// Root members named like String.prototype's HTML methods, called from +// TypeScript (pi's `chalk.bold(APP_NAME)`): the checker types chalk's +// JavaScript default export as a plain function, so the member is `any`. +show("bold", chalk.bold("bold")); +show("bold template", `${chalk.bold("Usage:")} pi`); +const alias = chalk; +show("bold alias", alias.bold("alias") + alias.italic("i")); show("red.bold", chalk.red.bold("bold red")); show("nested", chalk.blue("a " + chalk.red("b") + " c")); show("multi-arg", chalk.green("one", "two", 3)); @@ -36,6 +43,7 @@ show("gray-grey", chalk.gray("x") + chalk.grey("y")); for (const level of [0, 1, 2, 3] as const) { const c = new Chalk({ level }); show(`L${level} red.bold`, c.red.bold("x")); + show(`L${level} bold`, c.bold("x")); show(`L${level} hex`, c.hex("#123456")("x")); show(`L${level} rgb`, c.rgb(255, 255, 255).bgRgb(0, 0, 0)("x")); show(`L${level} ansi256`, c.ansi256(100).bgAnsi256(200)("x")); From 98b3ced80c5ed09b61b1413086c09e490d5aeeaf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:00:41 -0700 Subject: [PATCH 014/127] Resolve bare core-module names to the builtin over npm packages Node checks its builtins before node_modules for a bare core-module name, so an installed npm package named string_decoder (a transitive dependency of pi) never answers `import ... from "string_decoder"`. scriptc attributed the import to the package and refused it. The npm resolution now answers null for bare builtin names (prefix-only builtins such as node:test keep their bare names for packages), for ES imports and CommonJS requires alike. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/program.ts | 6 ++++++ .../main.ts.json | 7 +++++++ .../helper.cjs | 6 ++++++ .../6235-bare-builtin-shadowed-package/main.ts | 17 +++++++++++++++++ .../node_modules/events/events.js | 1 + .../node_modules/events/package.json | 1 + .../node_modules/string_decoder/index.js | 3 +++ .../node_modules/string_decoder/package.json | 1 + .../package.json | 1 + 9 files changed, 43 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/main.ts create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/package.json diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index df0cb673e..cf5287430 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3971,6 +3971,12 @@ function resolveNpmImport7( if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { return null; } + // Node resolves a bare core-module name (`string_decoder`, `events`, + // `fs/promises`) to the builtin before it looks in node_modules, so an + // installed npm package of the same name never answers it. Prefix-only + // builtins (`node:test`) are listed with their prefix, so a bare `test` + // still names the package. + if (nodeBuiltinNames.has(specifier)) return null; // --provenance-sources: a registered specifier is NOT an npm import — // its attested source compiles as program modules (resolveProjectModule diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json new file mode 100644 index 000000000..eee3db81a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs", + "/tests/corpus/6235-bare-builtin-shadowed-package/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs b/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs new file mode 100644 index 000000000..8e0a0457c --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs @@ -0,0 +1,6 @@ +// A CommonJS require of the bare names resolves to the builtins too. +const { StringDecoder } = require("string_decoder"); +exports.decodeEuro = () => { + const d = new StringDecoder("utf8"); + return d.write(Buffer.from([0xe2, 0x82])) + d.end(Buffer.from([0xac])); +}; diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/main.ts b/tests/corpus/6235-bare-builtin-shadowed-package/main.ts new file mode 100644 index 000000000..e9e83ea87 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/main.ts @@ -0,0 +1,17 @@ +// A bare core-module specifier names the Node builtin even when an npm +// package of the same name is installed (pi depends on string_decoder +// transitively): Node checks its builtins before node_modules. +import { StringDecoder } from "string_decoder"; +import { EventEmitter } from "events"; +import { createRequire } from "node:module"; + +const decoder = new StringDecoder("utf8"); +console.log(decoder.write(Buffer.from([0xe2, 0x82])) + decoder.end(Buffer.from([0xac]))); + +const emitter = new EventEmitter(); +emitter.on("ping", (n: number) => console.log("ping", n)); +emitter.emit("ping", 1); + +const require = createRequire(import.meta.url); +const helper = require("./helper.cjs") as { decodeEuro(): string }; +console.log("cjs", helper.decodeEuro()); diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js new file mode 100644 index 000000000..f1a344ef1 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js @@ -0,0 +1 @@ +throw new Error("the npm events package was loaded"); diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json new file mode 100644 index 000000000..0fb1a3c8a --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json @@ -0,0 +1 @@ +{ "name": "events", "version": "3.3.0", "main": "events.js" } diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js new file mode 100644 index 000000000..02af46d5a --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js @@ -0,0 +1,3 @@ +// An installed package named like the core module: Node never loads it +// for the bare specifier. +throw new Error("the npm string_decoder package was loaded"); diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json new file mode 100644 index 000000000..ad07adde8 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json @@ -0,0 +1 @@ +{ "name": "string_decoder", "version": "1.3.0", "main": "index.js" } diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/package.json b/tests/corpus/6235-bare-builtin-shadowed-package/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/package.json @@ -0,0 +1 @@ +{ "type": "module" } From 50235c42c50443e4dfd70691b81f03746d9a79d4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:12:48 -0700 Subject: [PATCH 015/127] Support pi-tui's terminal setup on process streams Three gaps on pi-tui's terminal start path: - `if (process.stdin.setRawMode)` read the method as a value (SC2020). The read now answers the stream object's member: the method on a TTY, undefined otherwise, as on Node. - process.stdin.setEncoding had no runtime method, and a string 'data' listener was fenced. stdin now decodes with the shared StringDecoder once per read (split multibyte sequences stay whole, end() flushes the tail before 'end'), string listeners receive the text, and dynamic listeners receive what Node delivers. A string listener with no encoding set throws the setEncoding-state TypeError, as child streams do. - process.stdout.on("resize", cb) (once/off/removeListener, stderr too) registers on the stream object, which listens for SIGWINCH and emits when a TTY's size changed; the listener never keeps the loop alive. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-process.ts | 13 +- .../src/frontend/lowering/builtins/process.ts | 90 +++++++- packages/compiler/src/ir/validate.ts | 3 +- .../main.ts.json | 6 + packages/runtime/src/scr_events.c | 215 +++++++++++++++++- packages/runtime/src/scr_events.h | 7 + .../6236-stdin-encoding-tty-probes/main.ts | 62 +++++ .../package.json | 1 + .../tsconfig.json | 7 + 9 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json create mode 100644 tests/corpus/6236-stdin-encoding-tty-probes/main.ts create mode 100644 tests/corpus/6236-stdin-encoding-tty-probes/package.json create mode 100644 tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 546c9a209..026727d9f 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -544,15 +544,24 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll B.line(`call void @scr_stdin_on_end(ptr ${cb.name}, i1 ${once.name})`); return { name: "", type: e.type }; } + // A string listener takes the setEncoding-decoded chunk. + const stringData = e.fn === "stdin.onData" && cbT.params[0]?.kind === "string"; const adapter = e.fn === "stdin.onData" ? cbT.params.length === 0 ? "scr_stdin_data_thunk0" - : "scr_stdin_data_thunk_bytes" + : stringData + ? "scr_stdin_data_thunk_str" + : "scr_stdin_data_thunk_bytes" : cbT.params.length === 0 ? "scr_child_err_thunk0" : "scr_child_err_thunk_error"; - const sym = e.fn === "stdin.onData" ? "scr_stdin_on_data" : "scr_stdin_on_error"; + const sym = + e.fn === "stdin.onData" + ? stringData + ? "scr_stdin_on_data_str" + : "scr_stdin_on_data" + : "scr_stdin_on_error"; host.declare(`declare void @${adapter}(ptr, ptr)`); host.declare(`declare void @${sym}(ptr, ptr, i1 zeroext)`); B.line(`call void @${sym}(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 777ec50e7..fa3cf1703 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -149,6 +149,18 @@ export function lowerProcessStreamProperty( loc: locOf(expr), }; } + // `if (process.stdin.setRawMode)`: Node's stdin has the method only + // when it is a TTY. The runtime's stream object answers the same + // presence (the method or undefined); calls keep their direct lowering. + if (direct === "stdin" && member === "setRawMode") { + return { + kind: "dynKeyGet", + value: lowerer.lowerExpr(expr.expression), + key: strLit(member, locOf(expr.name)), + type: DYN, + loc: locOf(expr), + }; + } if (member !== "isTTY" && member !== "columns" && member !== "rows") return null; let recv: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(recv) || ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) @@ -633,6 +645,73 @@ export function lowerProcessMethodCall( } return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc }; } + // process.stdout.on("resize", cb) (and once/off/removeListener/ + // addListener, stderr too): the terminal resize event, registered on the + // runtime's process stream object (a SIGWINCH listener that emits when a + // TTY's size changed; it never keeps the loop alive). Statement position: + // Node returns the stream. + if ( + ["on", "once", "off", "removeListener", "addListener"].includes(access.name.text) && + ts.isPropertyAccessExpression(access.expression) && + call.arguments.length === 2 && + !call.arguments.some(ts.isSpreadElement) && + ts.isExpressionStatement(call.parent) + ) { + const stream = lowerer.stdlibGlobalMember(access.expression, "process"); + const evT = lowerer.typeOf(call.arguments[0]!); + if ( + (stream === "stdout" || stream === "stderr") && + evT.isStringLiteralType() && + evT.value === "resize" + ) { + const loc = locOf(call); + const receiverLocal = lowerer.declareHiddenLocal("%stdioReceiver", DYN); + const receiver = varRef(receiverLocal.id, DYN, loc); + const method = + access.name.text === "off" + ? "removeListener" + : access.name.text === "addListener" + ? "on" + : access.name.text; + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: receiverLocal.id, + init: { + kind: "libCall", + fn: "process.stdio", + args: [numLit(stream === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }, + loc, + }, + ], + result: { + kind: "dynCall", + callee: { + kind: "dynKeyGet", + value: receiver, + key: strLit(method, locOf(access.name)), + type: DYN, + loc, + }, + receiver, + calleeName: access.getText(), + args: [ + lowerer.lowerExprExpecting(call.arguments[0]!, DYN), + lowerer.lowerExprExpecting(call.arguments[1]!, DYN), + ], + type: DYN, + loc, + }, + type: DYN, + loc, + }; + } + } // process.stdin.on/once("data" | "end" | "error", cb): the piped-stdin // event slice. A 'data' listener keeps the event loop alive until EOF // (Node's flowing stdin); 'end'/'error' listeners alone do not. `once` @@ -681,11 +760,18 @@ export function lowerProcessMethodCall( const param = cb.type.params[0]; const onceArg: IrExpr = { kind: "boolLit", value: once, type: BOOL, loc }; if (event === "data") { - if (param !== undefined && !(param.kind === "bytes" && param.elem === "u8")) { + // A string parameter receives the chunks process.stdin.setEncoding + // decodes (pi-tui's terminal input); the runtime refuses a delivery + // while no encoding is set, where Node would pass a Buffer. + if ( + param !== undefined && + !(param.kind === "bytes" && param.elem === "u8") && + param.kind !== "string" + ) { lowerer.unsupported( "SC1090", call.arguments[1]!, - `data listeners whose parameter is not 'Uint8Array' (got '${lowerer.fmt(param)}')`, + `data listeners whose parameter is not 'Uint8Array' or 'string' (got '${lowerer.fmt(param)}')`, ); } return { kind: "libCall", fn: "stdin.onData", args: [cb, onceArg], type: VOID, loc }; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 09ab7d951..bb218bc8e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5084,7 +5084,8 @@ function validateFunction( let ok = cbT?.kind === "func" && cbT.ret.kind === "void" && cbT.params.length <= 1; if (ok && cbT?.kind === "func" && cbT.params.length === 1) { const p = cbT.params[0]!; - if (e.fn === "stdin.onData") ok = p.kind === "bytes" && p.elem === "u8"; + if (e.fn === "stdin.onData") + ok = (p.kind === "bytes" && p.elem === "u8") || p.kind === "string"; else if (e.fn === "stdin.onError") ok = p.kind === "object" && p.className === "%Error"; else ok = p.kind === "f64"; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json new file mode 100644 index 000000000..c7546dd63 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6236-stdin-encoding-tty-probes/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 670ea10d6..1d4b878d4 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -345,9 +345,18 @@ static void scr_signals_drain(void) { * listener it is treated as EOF (piped fd 0 read errors are not * meaningfully reachable). */ +/* What a 'data' listener receives: raw chunks (typed Buffer listeners and + * the runtime's own consumers), the decoded string (typed string + * listeners, which need setEncoding), or whichever Node delivers + * (checked-dynamic listeners: the string once an encoding is set, else + * the Buffer). */ +enum { SCR_STDIN_DATA_BYTES, SCR_STDIN_DATA_STRING, SCR_STDIN_DATA_DYN }; + typedef struct { ScrClosure *cb; /* owned */ void (*fn)(ScrClosure *cb, ScrBytes *chunk); /* adapter: chunk borrowed */ + void (*str_fn)(ScrClosure *cb, ScrStr *text); /* STRING/DYN: text borrowed */ + unsigned char kind; bool once; } ScrStdinDataL; @@ -376,9 +385,16 @@ static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; static SCR_TL ScrBytes *scr_stdin_buffer; static SCR_TL size_t scr_stdin_read_demand; +/* process.stdin.setEncoding(enc): the canonical encoding name (NULL until + * set) and the StringDecoder's packed pending partial sequence, so a + * multibyte character split across reads decodes whole, as Node's + * decoder does. */ +static SCR_TL ScrStr *scr_stdin_encoding = NULL; +static SCR_TL double scr_stdin_dec_pending = 0; -void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), - bool once) { +static void scr_stdin_add_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), + void (*str_fn)(ScrClosure *, ScrStr *), unsigned char kind, + bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { scr_closure_release(cb); /* never fires again, like Node post-'end' */ return; @@ -390,10 +406,35 @@ void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrByt } scr_stdin_data[scr_stdin_ndata].cb = cb; scr_stdin_data[scr_stdin_ndata].fn = fn; + scr_stdin_data[scr_stdin_ndata].str_fn = str_fn; + scr_stdin_data[scr_stdin_ndata].kind = kind; scr_stdin_data[scr_stdin_ndata].once = once; scr_stdin_ndata++; } +void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), + bool once) { + scr_stdin_add_data(cb, fn, NULL, SCR_STDIN_DATA_BYTES, once); +} + +void scr_stdin_on_data_str(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrStr *), + bool once) { + scr_stdin_add_data(cb, NULL, fn, SCR_STDIN_DATA_STRING, once); +} + +static void scr_stdin_encoding_teardown(void) { + scr_str_release(scr_stdin_encoding); + scr_stdin_encoding = NULL; +} + +void scr_stdin_set_encoding(ScrStr *enc) { + if (scr_stdin_encoding == NULL) scr_atexit(scr_stdin_encoding_teardown); + ScrStr *previous = scr_stdin_encoding; + scr_stdin_encoding = scr_str_retain(enc); + scr_str_release(previous); + scr_stdin_dec_pending = 0; +} + void scr_stdin_on_end(ScrClosure *cb /*moves*/, bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { scr_closure_release(cb); @@ -484,10 +525,20 @@ static void scr_stdin_settle_done(void) { scr_promise_release(p); } +static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text); + /* EOF/teardown shared tail: 'end' fires (snapshot; once semantics are * moot — everything drops right after), the parked promise settles done, * and every listener is released. */ static void scr_stdin_finish(bool fire_end) { + if (fire_end && scr_stdin_encoding != NULL && scr_stdin_ndata > 0) { + /* The decoder's end() flush reaches the string listeners before 'end'. */ + ScrStr *tail = scr_strdec_end(scr_stdin_encoding, scr_stdin_dec_pending); + scr_stdin_dec_pending = 0; + if (tail->len > 0) scr_stdin_deliver_parts(NULL, tail); + scr_str_release(tail); + if (scr_exc_pending()) fire_end = false; + } scr_stdin_eof = true; if (fire_end) { size_t n = scr_stdin_nend; @@ -658,6 +709,20 @@ static void scr_stdin_service(void) { } static void scr_stdin_deliver(ScrBytes *chunk) { + if (scr_stdin_ndata > 0 && scr_stdin_encoding != NULL) { + /* Node's readable decodes once, in the stream: every listener sees + * the same string, and a read holding only a partial sequence emits + * nothing. */ + ScrStr *text = scr_strdec_write(scr_stdin_encoding, scr_stdin_dec_pending, chunk); + scr_stdin_dec_pending = scr_strdec_next(scr_stdin_encoding, scr_stdin_dec_pending, chunk); + if (text->len > 0) scr_stdin_deliver_parts(chunk, text); + scr_str_release(text); + return; + } + scr_stdin_deliver_parts(chunk, NULL); +} + +static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { if (scr_stdin_ndata > 0) { size_t nd = scr_stdin_ndata; ScrStdinDataL *snap = malloc(nd * sizeof *snap); @@ -678,11 +743,23 @@ static void scr_stdin_deliver(ScrBytes *chunk) { } } } - if (!scr_exc_pending()) snap[i].fn(snap[i].cb, chunk); + if (!scr_exc_pending()) { + if (snap[i].kind == SCR_STDIN_DATA_BYTES) { + if (chunk != NULL) snap[i].fn(snap[i].cb, chunk); + } else if (text != NULL) { + snap[i].str_fn(snap[i].cb, text); + } else if (snap[i].kind == SCR_STDIN_DATA_DYN) { + if (chunk != NULL) snap[i].fn(snap[i].cb, chunk); + } else { + static const char msg[] = + "stdin data listener type does not match setEncoding state"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + } + } scr_closure_release(snap[i].cb); } free(snap); - } else if (scr_stdin_waiter) { + } else if (scr_stdin_waiter && chunk != NULL) { ScrPromise *p = scr_stdin_waiter; scr_stdin_waiter = NULL; scr_promise_fulfill_ref(p, scr_bytes_retain(chunk), scr_bytes_retain_v, @@ -701,13 +778,20 @@ typedef struct { bool *error_once; size_t nerrors; ScrDyn *write_error; + /* 'resize' listeners (stdout/stderr) and the size they last saw. */ + ScrDyn **resizes; + bool *resize_once; + size_t nresizes; + double columns, rows; } ScrStdio; static SCR_TL ScrStdio scr_stdio_streams[3] = {{.fd = 0}, {.fd = 1}, {.fd = 2}}; enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, - STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_METHOD_COUNT }; + STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, + STDIO_SET_ENCODING, STDIO_METHOD_COUNT }; static const char *const scr_stdio_names[] = { - "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize" + "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize", + "setEncoding" }; static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; @@ -736,6 +820,11 @@ static void scr_stdio_data(ScrClosure *cb, ScrBytes *bytes) { scr_stdio_fire(cb, chunk, 1); scr_dyn_release(chunk); } +static void scr_stdio_data_text(ScrClosure *cb, ScrStr *text) { + ScrDyn *chunk = scr_dyn_new_str(text); + scr_stdio_fire(cb, chunk, 1); + scr_dyn_release(chunk); +} static void scr_stdio_end(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } static void scr_stdio_error(ScrClosure *cb, ScrStr *message) { ScrError *error = scr_error_new(SCR_ERR_ERROR, message); @@ -861,6 +950,93 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { return result; } +/* 'resize' on a TTY stdout/stderr: Node's tty.WriteStream listens for + * SIGWINCH and emits 'resize' when the window size changed. The signal + * listener does not keep the loop alive, and a non-TTY stream never + * emits. */ +static SCR_TL ScrDyn *scr_stdio_winch = NULL; + +static void scr_stdio_resize_emit(ScrStdio *stream) { + size_t n = stream->nresizes; + if (n == 0) return; + ScrDyn **snap = malloc(n * sizeof *snap); + bool *once = malloc(n * sizeof *once); + if (!snap || !once) scr_events_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = scr_dyn_retain(stream->resizes[i]); + once[i] = stream->resize_once[i]; + } + for (size_t i = 0; i < n; i++) { + if (once[i]) { + for (size_t j = 0; j < stream->nresizes; j++) { + if (stream->resizes[j] == snap[i]) { + scr_dyn_release(stream->resizes[j]); + memmove(stream->resizes + j, stream->resizes + j + 1, (stream->nresizes - j - 1) * sizeof *stream->resizes); + memmove(stream->resize_once + j, stream->resize_once + j + 1, (stream->nresizes - j - 1) * sizeof *stream->resize_once); + stream->nresizes--; + break; + } + } + } + if (!scr_exc_pending()) { + scr_dyn_this_push(stream, SCR_DYNH_STDIO); + ScrDyn *r = scr_dyn_call(snap[i], NULL, 0, "listener"); + scr_dyn_this_pop(); + scr_dyn_release(r); + } + scr_dyn_release(snap[i]); + } + free(snap); + free(once); +} + +static ScrDyn *scr_stdio_winch_fire(ScrClosure *cb, ScrDyn *const *args, size_t argc) { + (void)cb; (void)args; (void)argc; + for (int fd = 1; fd <= 2 && !scr_exc_pending(); fd++) { + ScrStdio *stream = &scr_stdio_streams[fd]; + if (stream->nresizes == 0 || !scr_process_is_tty(fd)) continue; + double columns = scr_process_columns(fd), rows = scr_process_rows(fd); + if (columns == stream->columns && rows == stream->rows) continue; + stream->columns = columns; + stream->rows = rows; + scr_stdio_resize_emit(stream); + } + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool remove, bool once) { + if (remove) { + for (size_t i = stream->nresizes; i > 0; i--) { + if (scr_dyn_strict_eq(stream->resizes[i - 1], listener)) { + scr_dyn_release(stream->resizes[i - 1]); + memmove(stream->resizes + i - 1, stream->resizes + i, (stream->nresizes - i) * sizeof *stream->resizes); + memmove(stream->resize_once + i - 1, stream->resize_once + i, (stream->nresizes - i) * sizeof *stream->resize_once); + stream->nresizes--; + break; + } + } + return; + } + if (stream->nresizes == 0) { + stream->columns = scr_process_columns(stream->fd); + stream->rows = scr_process_rows(stream->fd); + } + stream->resizes = realloc(stream->resizes, (stream->nresizes + 1) * sizeof *stream->resizes); + stream->resize_once = realloc(stream->resize_once, (stream->nresizes + 1) * sizeof *stream->resize_once); + if (!stream->resizes || !stream->resize_once) scr_events_oom(); + stream->resizes[stream->nresizes] = scr_dyn_retain(listener); + stream->resize_once[stream->nresizes++] = once; +#if !defined(_WIN32) && !defined(__wasi__) + if (scr_stdio_winch == NULL && scr_context_is_main()) { + ScrClosure *cb = scr_closure_new(NULL, 0); + scr_stdio_winch = scr_dyn_new_func(cb, scr_stdio_winch_fire, 0, "", "refreshSize"); + ScrStr *name = scr_str_new("SIGWINCH", 8); + scr_signal_on(name, scr_stdio_winch, false); + scr_str_release(name); + } +#endif +} + static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); ScrDyn *self = scr_dyn_this_get(); @@ -920,14 +1096,20 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc bool data = arg->kind == SCR_DYN_STR && arg->v.str->len == 4 && memcmp(event, "data", 4) == 0; bool end = arg->kind == SCR_DYN_STR && arg->v.str->len == 3 && memcmp(event, "end", 3) == 0; bool error = arg->kind == SCR_DYN_STR && arg->v.str->len == 5 && memcmp(event, "error", 5) == 0; - if ((!data && !end && !error) || (stream->fd != 0 && !error)) { + bool resize = stream->fd != 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 6 && + memcmp(event, "resize", 6) == 0; + if ((!data && !end && !error && !resize) || (stream->fd != 0 && !error && !resize)) { result = scr_stdio_refusal("event"); goto done; } bool remove = method == STDIO_OFF, once = method == STDIO_ONCE; - if (stream->fd == 0) { + if (resize) { + scr_stdio_resize_listener(stream, listener, remove, once); + } else if (stream->fd == 0) { if (!remove) { - if (data) scr_stdin_on_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, once); + if (data) + scr_stdin_add_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, + scr_stdio_data_text, SCR_STDIN_DATA_DYN, once); if (end) scr_stdin_on_end(scr_stdio_callback(listener, (void *)scr_stdio_end), once); if (error) scr_stdin_on_error(scr_stdio_callback(listener, (void *)scr_stdio_error), scr_stdio_error, once); } else { @@ -972,6 +1154,13 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } else if (stream->fd == 0 && method == STDIO_RAW) { scr_process_stdin_set_raw_mode(scr_dyn_truthy(arg)); if (!scr_exc_pending()) { scr_stdio_raw = scr_dyn_truthy(arg); result = scr_dyn_retain(self); } + } else if (stream->fd == 0 && method == STDIO_SET_ENCODING) { + ScrStr *enc = scr_strdec_encoding(arg); + if (enc) { + scr_stdin_set_encoding(enc); + scr_str_release(enc); + result = scr_dyn_retain(self); + } } else if (stream->fd == 0 && method == STDIO_DESTROY && argc == 0) { scr_stdin_destroy(); result = scr_dyn_retain(self); @@ -1069,7 +1258,12 @@ static void scr_stdio_cleanup(void) { for (size_t j = 0; j < stream->nerrors; j++) scr_dyn_release(stream->errors[j]); free(stream->errors); free(stream->error_once); stream->errors = NULL; stream->error_once = NULL; stream->nerrors = 0; + for (size_t j = 0; j < stream->nresizes; j++) scr_dyn_release(stream->resizes[j]); + free(stream->resizes); free(stream->resize_once); + stream->resizes = NULL; stream->resize_once = NULL; stream->nresizes = 0; } + scr_dyn_release(scr_stdio_winch); + scr_stdio_winch = NULL; for (int i = 0; i < STDIO_METHOD_COUNT; i++) { scr_dyn_release(scr_stdio_methods[i]); scr_stdio_methods[i] = NULL; @@ -1213,6 +1407,9 @@ void scr_stdin_data_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { /* The listener owns its +1 param per the universal convention. */ ((void (*)(ScrClosure *, ScrBytes *))cb->fn)(cb, scr_bytes_retain(chunk)); } +void scr_stdin_data_thunk_str(ScrClosure *cb, ScrStr *text) { + ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, scr_str_retain(text)); +} /* ── the loop hooks (see scr_async.c) ─────────────────────────────────── */ diff --git a/packages/runtime/src/scr_events.h b/packages/runtime/src/scr_events.h index 7dcf9cda0..a0025783e 100644 --- a/packages/runtime/src/scr_events.h +++ b/packages/runtime/src/scr_events.h @@ -42,6 +42,13 @@ void scr_run_exit_listeners(double code); void scr_exit_thunk0(ScrClosure *cb, double code); void scr_exit_thunk_code(ScrClosure *cb, double code); void scr_stdin_on_data(ScrClosure *cb, void (*fn)(ScrClosure *, ScrBytes *), bool once); +/* A string 'data' listener (moves cb): receives each chunk decoded by the + * stdin's setEncoding decoder; with no encoding set, a delivery throws + * the setEncoding-state TypeError instead (Node would pass a Buffer). */ +void scr_stdin_on_data_str(ScrClosure *cb, void (*fn)(ScrClosure *, ScrStr *), bool once); +/* process.stdin.setEncoding: the canonical encoding (borrowed). */ +void scr_stdin_set_encoding(ScrStr *enc); +void scr_stdin_data_thunk_str(ScrClosure *cb, ScrStr *text); void scr_stdin_on_end(ScrClosure *cb, bool once); void scr_stdin_on_error(ScrClosure *cb, ScrChildErrFn fn, bool once); ScrPromise *scr_stdin_next_chunk(void); /* +1 */ diff --git a/tests/corpus/6236-stdin-encoding-tty-probes/main.ts b/tests/corpus/6236-stdin-encoding-tty-probes/main.ts new file mode 100644 index 000000000..0fbe7eb99 --- /dev/null +++ b/tests/corpus/6236-stdin-encoding-tty-probes/main.ts @@ -0,0 +1,62 @@ +// pi-tui's terminal setup on a piped stdin: the `if (process.stdin.setRawMode)` +// presence check (the method exists only on a TTY), setEncoding with a +// string 'data' listener whose chunks keep multibyte characters split +// across writes whole, and stdout 'resize' listeners (registered, +// removed, never fired off a TTY). The program re-runs itself with a +// pipe so the parent controls the write boundaries. +import { spawn } from "node:child_process"; + +if (process.argv[2] === "child") { + console.log("raw present", typeof process.stdin.setRawMode, Boolean(process.stdin.setRawMode)); + if (process.stdin.setRawMode) process.stdin.setRawMode(true); + process.stdin.setEncoding("utf8"); + const onResize = (): void => console.log("resize", process.stdout.columns); + process.stdout.on("resize", onResize); + process.stdout.once("resize", onResize); + process.stdout.removeListener("resize", onResize); + let total = ""; + let replacement = false; + process.stdin.on("data", (data: string) => { + if (typeof data !== "string") console.log("not a string"); + if (data.includes("�")) replacement = true; + total += data; + }); + process.stdin.on("end", () => { + console.log("end", JSON.stringify(total), "replacement", replacement); + process.stdout.removeListener("resize", onResize); + }); + process.stdin.resume(); + // Off a TTY no stream listens for SIGWINCH and its default is to ignore. + process.kill(process.pid, "SIGWINCH"); +} else { + const child = spawn(process.execPath, [process.argv[1]!, "child"], { + stdio: ["pipe", "pipe", "inherit"], + }); + let out = ""; + child.stdout!.setEncoding("utf8"); + child.stdout!.on("data", (chunk: string) => { + out += chunk; + }); + child.on("close", (code: number | null) => { + process.stdout.write(out); + console.log("child exit", code); + }); + const parts = [ + Buffer.from([0x68, 0xc3]), + Buffer.from([0xa9, 0x6c, 0x6c, 0x6f, 0x20, 0xe2, 0x82]), + Buffer.from([0xac, 0x20, 0xf0, 0x9f]), + Buffer.from([0x98, 0x80, 0x21]), + ]; + let i = 0; + const next = (): void => { + if (i < parts.length) { + const part: Buffer = parts[i]!; + i++; + child.stdin!.write(part); + setTimeout(next, 30); + } else { + child.stdin!.end(); + } + }; + next(); +} diff --git a/tests/corpus/6236-stdin-encoding-tty-probes/package.json b/tests/corpus/6236-stdin-encoding-tty-probes/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/6236-stdin-encoding-tty-probes/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json b/tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json new file mode 100644 index 000000000..ae4eda194 --- /dev/null +++ b/tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true + } +} From c600e8daa52121751495c1707c307bb7f7396422 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:18:47 -0700 Subject: [PATCH 016/127] Delete V8's own stack on Error subclasses with a stack accessor quickjs-wasi's JSException constructor runs `delete this.stack` so V8's own stack property stops shadowing the class's stack accessor; scriptc fenced the delete, so every guest exception reaching the host threw the fence error. Compiled errors keep the trace internally and class dispatch already reads the accessor, so the delete now evaluates its receiver and nothing else, for Error subclasses whose chain declares a stack getter. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 22 +++++++++++++++++++ .../main.js.json | 6 +++++ .../6237-error-subclass-delete-stack/main.js | 12 ++++++++++ .../node_modules/guest-error/index.d.ts | 6 +++++ .../node_modules/guest-error/index.js | 18 +++++++++++++++ .../node_modules/guest-error/package.json | 1 + .../package.json | 1 + 7 files changed, 66 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json create mode 100644 tests/corpus/6237-error-subclass-delete-stack/main.js create mode 100644 tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts create mode 100644 tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js create mode 100644 tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json create mode 100644 tests/corpus/6237-error-subclass-delete-stack/package.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 784036fdd..43babf5ab 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5792,6 +5792,28 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt }; } let obj = lowerer.lowerExpr(target.expression); + // `delete this.stack` in an Error subclass that declares a `stack` + // accessor (quickjs-wasi's JSException): V8 gives each error an own + // `stack` that shadows the accessor, and the delete removes it so reads + // reach the accessor. Compiled errors keep the trace internally and + // class dispatch already reads the accessor, so only the receiver's + // evaluation remains. + if ( + obj.type.kind === "object" && + ts.isPropertyAccessExpression(target) && + target.name.text === "stack" + ) { + let accessor = false; + let errorBase = false; + for (let c = lowerer.classes.get(obj.type.className); c; c = c.base ?? undefined) { + if (c.builtinError) { + errorBase = true; + break; + } + if (c.methods.has("get:stack")) accessor = true; + } + if (accessor && errorBase) return { kind: "exprStmt", expr: obj, loc }; + } if (isJsSourceFile(expr.getSourceFile()) && obj.type.kind === "object") { const info = lowerer.classes.get(obj.type.className); const keyType = ts.isElementAccessExpression(target) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json new file mode 100644 index 000000000..a48385871 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6237-error-subclass-delete-stack/main.js" + ], + "diags": [] +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/main.js b/tests/corpus/6237-error-subclass-delete-stack/main.js new file mode 100644 index 000000000..96ca7593d --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/main.js @@ -0,0 +1,12 @@ +// Deleting `stack` on an Error subclass that declares a stack accessor +// lets reads reach the accessor (the package's guest exceptions). +import { GuestError } from "guest-error"; + +try { + throw new GuestError(7); +} catch (e) { + console.log(e instanceof GuestError, e instanceof Error, e.handle, e.message); + console.log(e.stack); + e.stack = "rewritten"; + console.log(e.stack, Object.keys(e)); +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts new file mode 100644 index 000000000..7f3b83f94 --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts @@ -0,0 +1,6 @@ +export declare class GuestError extends Error { + handle: number; + constructor(handle: number); + get stack(): string; + set stack(v: string); +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js new file mode 100644 index 000000000..693c06b51 --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js @@ -0,0 +1,18 @@ +// quickjs-wasi's JSException shape: an Error subclass whose stack +// accessor pair the constructor exposes by deleting V8's own `stack`. +export class GuestError extends Error { + handle; + #stack; + constructor(handle) { + super("guest error " + handle); + this.handle = handle; + delete this.stack; + this.#stack = "guest stack " + handle; + } + get stack() { + return this.#stack; + } + set stack(v) { + this.#stack = v; + } +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json new file mode 100644 index 000000000..99f59d07b --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json @@ -0,0 +1 @@ +{"name": "guest-error", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts"} diff --git a/tests/corpus/6237-error-subclass-delete-stack/package.json b/tests/corpus/6237-error-subclass-delete-stack/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/package.json @@ -0,0 +1 @@ +{ "type": "module" } From e250214e0309c6ab2a6c3f0612563ad7830b6e4a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:40:50 -0700 Subject: [PATCH 017/127] Name objects built by plain constructor functions in util.inspect console.log(new F(2)) printed `{ a: 2 }` where Node prints `F { a: 2 }`. A construction through a plain function now stamps the first prototype on the receiver's chain whose own `constructor` data property holds a named function with that name, as Node's getConstructorName finds it. The dyn printer already reads such runtime class names, so its code is unchanged; stamped prototypes stay alive and are cleared before release at exit. `new F()` boxes F under its name so the prototype's constructor carries it. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 12 +++++- .../es5-constructor-inspect-names.cjs.json | 6 +++ packages/runtime/src/scr_json.c | 42 +++++++++++++++++++ .../corpus/es5-constructor-inspect-names.cjs | 41 ++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json create mode 100644 tests/corpus/es5-constructor-inspect-names.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8bb3fd8cb..0baaeb0ed 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -85,7 +85,7 @@ import { isNodeTypesPath, locOf, } from "../program.js"; -import { PoisonError, dynFallbackType, newFnCtx, own } from "./lowerer.js"; +import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js"; import { isAssertClassNew } from "./assert-instances.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; @@ -9715,7 +9715,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { ((callee.type.kind === "func" || callee.type.kind === "classval") && lowerer.dynConvertible(callee.type))) ) { - return lowerCheckedConstructor(lowerer, expr, lowerer.coerceToExpected(callee, DYN)); + let boxed = lowerer.coerceToExpected(callee, DYN); + // A plain function's box carries its name: the construction's + // prototype records it as `constructor`, which util.inspect prints + // (`F { a: 2 }`). + if (boxed.kind === "dynFrom" && boxed.fnName === undefined && callee.type.kind === "func") { + const name = jsFuncNameOf(expr.expression, lowerer); + if (name !== null) boxed = { ...boxed, fnName: name }; + } + return lowerCheckedConstructor(lowerer, expr, boxed); } lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json new file mode 100644 index 000000000..b5e8ba087 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/es5-constructor-inspect-names.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/es5-constructor-inspect-names.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b23e78241..453cd6ac6 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4336,6 +4336,47 @@ bool scr_dyn_data_view_is(const ScrDyn *value) { return value->kind == SCR_DYN_BYTES && value->v.bytes->is_data_view; } +/* util.inspect names an object after the function held by the first own + * `constructor` data property on its prototype chain (Node's + * getConstructorName). The dyn printer reads runtime class names + * (v.obj.class_name, set only on prototypes that stay alive), so a + * construction through a plain function stamps that prototype with the + * function's static name and keeps it alive; the stamps are cleared before + * release at exit, so freelist reuse never sees them. */ +static SCR_TL ScrDyn **scr_dyn_named_prototypes; +static SCR_TL size_t scr_dyn_named_count, scr_dyn_named_cap; + +static void scr_dyn_named_prototypes_cleanup(void) { + for (size_t i = 0; i < scr_dyn_named_count; i++) { + scr_dyn_named_prototypes[i]->v.obj.class_name = NULL; + scr_dyn_release(scr_dyn_named_prototypes[i]); + } + free(scr_dyn_named_prototypes); + scr_dyn_named_prototypes = NULL; + scr_dyn_named_count = scr_dyn_named_cap = 0; +} + +static void scr_dyn_name_prototype_chain(const ScrDyn *object) { + for (ScrDyn *p = object->prototype; p; p = p->prototype) { + if (p->kind != SCR_DYN_OBJ || p->v.obj.class_name) return; + ScrDynEntry *entry = scr_dyn_find_entry(p, "constructor", 11); + if (!entry || entry->accessor || !entry->value || entry->value->kind != SCR_DYN_FUNC) continue; + const char *name = entry->value->v.fn.name; + if (!name || !name[0]) continue; + if (scr_dyn_named_count == scr_dyn_named_cap) { + if (scr_dyn_named_cap == 0) scr_atexit(scr_dyn_named_prototypes_cleanup); + size_t cap = scr_dyn_named_cap ? scr_dyn_named_cap * 2 : 8; + ScrDyn **grown = realloc(scr_dyn_named_prototypes, cap * sizeof *grown); + if (!grown) scr_json_oom(); + scr_dyn_named_prototypes = grown; + scr_dyn_named_cap = cap; + } + p->v.obj.class_name = name; + scr_dyn_named_prototypes[scr_dyn_named_count++] = scr_dyn_retain(p); + return; + } +} + /* Ordinary native function constructors retain their live prototype table. * A constructor may replace the fresh receiver with another object. */ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) { @@ -4346,6 +4387,7 @@ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr ScrDyn *receiver = scr_iterator_object(prototype) ? scr_dyn_obj_create(prototype) : scr_dyn_new_obj(); scr_dyn_release(prototype); if (!receiver) return NULL; + scr_dyn_name_prototype_chain(receiver); scr_dyn_this_push_dyn(receiver); ScrDyn *result = scr_dyn_call(callee, args->v.arr.items, args->v.arr.len, what->data); scr_dyn_this_pop(); diff --git a/tests/corpus/es5-constructor-inspect-names.cjs b/tests/corpus/es5-constructor-inspect-names.cjs new file mode 100644 index 000000000..b7cc84349 --- /dev/null +++ b/tests/corpus/es5-constructor-inspect-names.cjs @@ -0,0 +1,41 @@ +// util.inspect names objects built by plain constructor functions after +// the constructor their prototype chain records, as Node does. +'use strict'; +const util = require('util'); +function Point(x, y) { + this.x = x; + this.y = y; +} +Point.prototype.norm = function () { + return Math.hypot(this.x, this.y); +}; +const p = new Point(3, 4); +console.log(p, p.norm()); +p.z = 0; +console.log(p); +function Empty() {} +console.log(new Empty(), [new Empty()], { inner: new Empty() }); +console.log(util.inspect(new Point(1, 2))); +console.log(`${util.inspect({ list: [new Point(0, 1)] })}`); +// A subclass whose prototype was replaced without a constructor reports +// the nearest constructor on the chain. +function Base(n) { + this.n = n; +} +function Derived(n) { + Base.call(this, n); + this.extra = true; +} +Derived.prototype = Object.create(Base.prototype); +console.log(new Derived(1)); +Derived.prototype.constructor = Derived; +console.log(new Derived(2)); +// A replaced prototype object without a constructor prints plainly. +function Plain() { + this.v = 1; +} +Plain.prototype = { get() { return this.v; } }; +console.log(new Plain()); +const made = Object.create(Point.prototype); +made.x = 9; +console.log(made); From 8514c45b5351342a54a0a578dbbc1eee854d5d63 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:31 -0700 Subject: [PATCH 018/127] Make builtin module functions real function values in JavaScript uuid 11 threw `TypeError: native.randomUUID is not a function` where Node works. Root cause: a builtin module function used as a JavaScript value without a function-value adapter lowered to a string identity token ("[builtin crypto.randomUUID]"); uuid's native.js stores it in an exported record (`export default { randomUUID }`), so the call hit a string and typeof answered "string". crypto.randomUUID and randomUUIDv7 get value adapters (randomUUID's through its options-validating entry point, the new valueFn surface field), boxed adapters carry the builtin's name, and every other builtin module function value is one function per builtin that throws the named SC1090 fence when called, keeping identity, typeof and inspection Node's. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtin-function-fences.ts | 52 +++++++++++++++++++ .../src/frontend/lowering/lower-exprs.ts | 4 ++ .../compiler/src/frontend/lowering/lowerer.ts | 14 +++-- .../src/frontend/lowering/surfaces.ts | 21 +++++++- .../main.ts.json | 7 +++ .../corpus/builtin-function-values.mjs.json | 6 +++ .../builtin-function-record-values/main.ts | 10 ++++ .../builtin-function-record-values/native.js | 3 ++ tests/corpus/builtin-function-values.mjs | 17 ++++++ 9 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtin-function-fences.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json create mode 100644 tests/corpus/builtin-function-record-values/main.ts create mode 100644 tests/corpus/builtin-function-record-values/native.js create mode 100644 tests/corpus/builtin-function-values.mjs diff --git a/packages/compiler/src/frontend/lowering/builtin-function-fences.ts b/packages/compiler/src/frontend/lowering/builtin-function-fences.ts new file mode 100644 index 000000000..e6221384b --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtin-function-fences.ts @@ -0,0 +1,52 @@ +/** A builtin module function used as a JavaScript value when it has no + * value adapter (lowerNativeCallableValue). Node hands the program a real + * function; the compiled value is one function per builtin that throws the + * named fence when called. Identity flows (`fn === fs.readFileSync`, Set + * membership) still hold, `typeof` answers "function" and inspection + * prints `[Function: name]`, where a string token would answer "string" + * and turn a call into an ordinary "is not a function" TypeError. */ +import * as ts from "../ts7/adapter.js"; +import { DYN, type IrExpr, type SrcLoc, funcOf } from "../../ir/ir.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import type { Lowerer } from "./lowerer.js"; + +export function builtinFunctionFenceValue( + lowerer: Lowerer, + bi: { module: string; member: string }, + node: ts.Identifier, + loc: SrcLoc, +): IrExpr { + const display = `${bi.module}.${bi.member}`; + const name = `%builtin.fence.${display}`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const diag = unsupportedDiag( + "SC1090", + loc, + `library functions as values ('${display}' has no function-value lowering; call it directly)`, + ); + lowerer.runtimeFences.push(diag); + const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), loc.start).line + 1; + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [ + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + loc, + }); + } + return { + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcOf([], DYN), loc }, + fnName: bi.member, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d3deaa719..10b09acb1 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -31,6 +31,7 @@ import { literalUnionArm } from "../union-discriminants.js"; * (FieldTarget). */ import { isFsStreamMember, lowerFsStreamProperty } from "./builtins/fs-streams.js"; import * as ts from "../ts7/adapter.js"; +import { builtinFunctionFenceValue } from "./builtin-function-fences.js"; import { isZlibStreamMember, lowerZlibInstanceOf, @@ -1271,6 +1272,9 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // the same identity-token story as stdlib globals above (the // harness adds worker_threads.Worker to its identity Set). if (isJsSourceFile(expr.getSourceFile())) { + // A function member is a function value that fences when called. + if (builtinModuleFnOf(lowerer, bi.module, bi.member)) + return builtinFunctionFenceValue(lowerer, bi, expr, loc); return { kind: "strLit", value: `[builtin ${bi.module}.${bi.member}]`, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cdc170364..b0efdd048 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2434,6 +2434,8 @@ export class Lowerer { * so named imports, namespace reads, CommonJS bindings, and alias chains * all reuse one immortal zero-capture closure. */ readonly builtinCallableValueFns = new Map(); + /** Each builtin function-value adapter's Node name, for boxed values. */ + readonly builtinCallableValueNames = new Map(); /** Optional-chain lowering state. While a chain body lowers, the guarded * receiver NODE reads as a chainRecv (typed by the narrowed arm) instead * of re-lowering, its checker type reads non-nullish (typeOf), and the @@ -8217,12 +8219,17 @@ export class Lowerer { liveReference(expr.type) || (expr.type.kind === "union" && (this.unions.get(expr.type.unionId)?.arms.some(liveReference) ?? false)); + // A builtin function's adapter boxes under the builtin's name, the + // name Node's function carries (`[Function: randomUUID]`). + const builtinName = + expr.kind === "closure" ? this.builtinCallableValueNames.get(expr.fnName) : undefined; return { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc, ...(liveRef ? { liveRef: true as const } : {}), + ...(builtinName !== undefined ? { fnName: builtinName } : {}), }; } return expr; @@ -11923,11 +11930,12 @@ export class Lowerer { param.mode === "optional" ? `${param.mode}:${param.defaultValue}` : param.mode, ) .join(","); - const key = `${fn.fn}:${typeKey(funcType)}:${adapterShape}`; + const key = `${fn.valueFn ?? fn.fn}:${typeKey(funcType)}:${adapterShape}`; let fnName = this.builtinCallableValueFns.get(key); if (!fnName) { - fnName = `%builtin.value.${fn.fn}.${this.builtinCallableValueFns.size}`; + fnName = `%builtin.value.${fn.valueFn ?? fn.fn}.${this.builtinCallableValueFns.size}`; this.builtinCallableValueFns.set(key, fnName); + this.builtinCallableValueNames.set(fnName, display.slice(display.lastIndexOf(".") + 1)); const params = funcType.params.map((type, index) => ({ localId: `arg.${index}`, name: `arg${index}`, @@ -11992,7 +12000,7 @@ export class Lowerer { loc, }; }); - const call: IrExpr = { kind: "libCall", fn: fn.fn, args, type: fn.result, loc }; + const call: IrExpr = { kind: "libCall", fn: fn.valueFn ?? fn.fn, args, type: fn.result, loc }; const body: IrStmt[] = fn.result.kind === "void" ? [ diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 73cc58c45..2952ee587 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -793,6 +793,10 @@ export interface BuiltinModuleFn { * omission or explicit undefined; rest parameters pack into one typed * array slot. Entries with call-site-specific validation remain absent. */ valueParams?: BuiltinValueParam[]; + /** The libCall a function value's adapter makes when it differs from the + * direct-call row's `fn` (a direct call picks an arity-specific entry + * point; the value accepts every arity through one). */ + valueFn?: IrLibFn; } export type BuiltinValueParam = @@ -1502,7 +1506,15 @@ export const BUILTIN_MODULE_FNS: Record< userInfo: { fn: "os.userName", params: [], result: VOID }, }, crypto: { - randomUUID: { fn: "crypto.randomUUID", params: [], result: STRING }, + randomUUID: { + fn: "crypto.randomUUID", + params: [], + result: STRING, + // As a value (uuid's `export default { randomUUID }`), the optional + // options argument goes through the validating entry point. + valueFn: "crypto.randomUUIDOpts", + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + }, // A real Buffer result. The composed randomBytes(n).toString(enc) // form KEEPS its one-libCall lowering (lowerCryptoComposedCall runs // first and the Buffer never materializes there); this entry covers @@ -1523,7 +1535,12 @@ export const BUILTIN_MODULE_FNS: Record< randomFillSync: { fn: "crypto.randomFill", params: [BYTES_U8, F64, F64], result: BYTES_U8 }, randomInt: { fn: "crypto.randomInt", params: [F64, F64], result: F64 }, randomFill: { fn: "crypto.randomFillCb", params: [], result: VOID }, - randomUUIDv7: { fn: "crypto.randomUUIDv7", params: [DYN], result: STRING }, + randomUUIDv7: { + fn: "crypto.randomUUIDv7", + params: [DYN], + result: STRING, + valueParams: [{ mode: "optional", type: DYN, defaultValue: undefined }], + }, createSecretKey: { fn: "crypto.createSecretKey", params: [DYN, DYN], result: DYN }, generateKeySync: { fn: "crypto.generateKeySync", params: [DYN, DYN], result: DYN }, generateKey: { fn: "crypto.generateKeyCb", params: [], result: VOID }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json new file mode 100644 index 000000000..eb79fc63a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-record-values/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/builtin-function-record-values/native.js", + "/tests/corpus/builtin-function-record-values/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json new file mode 100644 index 000000000..f13bd6642 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-function-values.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/builtin-function-values.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/builtin-function-record-values/main.ts b/tests/corpus/builtin-function-record-values/main.ts new file mode 100644 index 000000000..c742acc16 --- /dev/null +++ b/tests/corpus/builtin-function-record-values/main.ts @@ -0,0 +1,10 @@ +// A builtin function stored in a JavaScript record is a real function: +// TypeScript consumers call it through the record. +import native from "./native.js"; +const shape = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const id = native.randomUUID(); +console.log(typeof id, id.length, shape.test(id)); +const ids = new Set(); +for (let i = 0; i < 50; i++) ids.add(native.randomUUID()); +console.log(ids.size); +console.log(shape.test(native.randomUUID({ disableEntropyCache: true }))); diff --git a/tests/corpus/builtin-function-record-values/native.js b/tests/corpus/builtin-function-record-values/native.js new file mode 100644 index 000000000..7009a2cd9 --- /dev/null +++ b/tests/corpus/builtin-function-record-values/native.js @@ -0,0 +1,3 @@ +// uuid's native.js layout: a record exporting a builtin function value. +import { randomUUID } from "crypto"; +export default { randomUUID }; diff --git a/tests/corpus/builtin-function-values.mjs b/tests/corpus/builtin-function-values.mjs new file mode 100644 index 000000000..ad0331291 --- /dev/null +++ b/tests/corpus/builtin-function-values.mjs @@ -0,0 +1,17 @@ +// Builtin functions used as JavaScript values are functions: typeof, +// identity, their name, and calls through the value. +import { randomUUID, scryptSync } from "crypto"; +const table = { randomUUID, scryptSync }; +console.log(typeof table.randomUUID, table.randomUUID === randomUUID); +console.log(/^[0-9a-f-]{36}$/.test(table.randomUUID())); +console.log(/^[0-9a-f-]{36}$/.test(table.randomUUID(undefined))); +try { + table.randomUUID(5); +} catch (e) { + console.log(e.code, e.name); +} +console.log(table.randomUUID); +console.log(typeof table.scryptSync, table.scryptSync === scryptSync); +console.log(new Set([scryptSync]).has(table.scryptSync), table.scryptSync.name); +const pick = (useNative) => (useNative && table.randomUUID ? table.randomUUID() : "fallback"); +console.log(pick(true).length, pick(false)); From 815ce664e59b45d03b51590fea5a0c65da339e17 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:31 -0700 Subject: [PATCH 019/127] Let writes initialize a module let declared without an initializer `let f: T; f = (n) => ... f(n - 1)` at module scope threw "Cannot access 'f' before initialization": a function-typed (pointer-guarded) binding kept the null-pointer TDZ test on its writes, but the declaration already initialized it (to undefined) when it ran. Such bindings now carry the existing initialization flag: the declaration sets it, writes that can run earlier check it, and writes no longer test the empty pointer. A write that really precedes the declaration still throws. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/cycle-initialization.ts | 45 ++++++++++++++++++ .../src/frontend/lowering/lower-modules.ts | 5 ++ .../compiler/src/frontend/lowering/lowerer.ts | 7 +++ ...let-uninitialized-function-binding.ts.json | 6 +++ .../let-uninitialized-function-binding.ts | 46 +++++++++++++++++++ 5 files changed, 109 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json create mode 100644 tests/corpus/let-uninitialized-function-binding.ts diff --git a/packages/compiler/src/frontend/lowering/cycle-initialization.ts b/packages/compiler/src/frontend/lowering/cycle-initialization.ts index 1e8b9e5a5..fb62fb998 100644 --- a/packages/compiler/src/frontend/lowering/cycle-initialization.ts +++ b/packages/compiler/src/frontend/lowering/cycle-initialization.ts @@ -41,6 +41,10 @@ export interface CycleInitFlag { * own module's early calls is checked opportunistically: whatever cannot * be checked keeps the unchecked behavior instead of refusing. */ cycle: boolean; + /** A pointer-guarded binding declared without an initializer: its + * declaration initializes it (to undefined, which the pointer cannot + * hold), so the flag alone decides its writes (markUninitializedWrites). */ + uninitialized?: true; /** A class binding: its references are the class's own IR forms and its * static field storage (`global` is the flag itself). */ className?: string; @@ -107,6 +111,30 @@ export function markCycleEarlyBindings(lowerer: Lowerer): void { registerInitFlag(lowerer, binding, global, false); } } + // `let f: T;` with a pointer-guarded slot: once the declaration ran the + // binding is initialized while the slot is still empty, so a write + // checks the flag (where code can run before the declaration) instead of + // the empty pointer. `f = () => f(...)` is the shape. + for (const [id, entry] of lowerer.uninitializedLexicalGlobals) { + const global = lowerer.globalsList.find((g) => g.id === id); + if (global === undefined) continue; + if (!lowerer.cycleInitFlags.has(id)) { + registerInitFlag( + lowerer, + { + symbol: entry.symbol, + name: entry.name, + kind: "lexical", + module: entry.module, + reference: entry.name, + settled: new Set(), + }, + global, + false, + ); + } + lowerer.cycleInitFlags.get(id)!.uninitialized = true; + } } /** A class that its module's early-running code can reach before the class @@ -304,6 +332,22 @@ export function checkCycleBindingAccesses(lowerer: Lowerer, mod: IrModule): void const fileOfInit = new Map(); for (const [sf, name] of lowerer.initNameOf) fileOfInit.set(name, sf); for (const fn of mod.functions) rewriteFunction(lowerer, fn, fileOfInit.get(fn.name)); + markUninitializedWrites(lowerer, mod); +} + +/** Writes to a binding declared without an initializer that run after its + * declaration store into an initialized binding whose slot may still be + * empty: the pointer guard must not refuse them. Writes that can run + * earlier carry the flag check rewriteFunction inserted. */ +function markUninitializedWrites(lowerer: Lowerer, mod: IrModule): void { + const ids = new Set(); + for (const [id, flag] of lowerer.cycleInitFlags) if (flag.uninitialized) ids.add(id); + if (ids.size === 0) return; + const mark = (s: IrStmt): IrStmt => + s.kind === "assign" && !s.initializes && ids.has(s.localId) ? { ...s, initializes: true } : s; + const rewriteExpr = (e: IrExpr): IrExpr => mapExprChildren(e, rewriteExpr, rewriteStmt); + const rewriteStmt = (s: IrStmt): IrStmt => mark(mapStmtChildren(s, rewriteExpr, rewriteStmt)); + for (const fn of mod.functions) fn.body = fn.body.map(rewriteStmt); } function rewriteFunction( @@ -444,6 +488,7 @@ function rewriteFunction( kind: "assign", localId: s.localId, value: { kind: "varRef", localId: t, type: s.value.type, loc: s.loc }, + ...(flag.uninitialized ? { initializes: true as const } : {}), loc: s.loc, }; return { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index da70cd53a..96fd170f8 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2426,6 +2426,11 @@ export function collectGlobals( }; lowerer.globalsBySymbol.set(symbol, g); lowerer.globalsList.push(g); + // `let f: T;` initializes the binding (to undefined) where the + // declaration runs: later writes are ordinary stores, which the + // pointer guard alone would refuse (markCycleEarlyBindings). + if (g.tdz && !decl.initializer) + lowerer.uninitializedLexicalGlobals.set(g.id, { name: nameNode, module: sf, symbol }); // Mutable checked-dynamic LET globals ride the same entry // init: a closure called above the declaration statement // reads the dyn undefined instead of faulting on NULL — the diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b0efdd048..7eaf92f36 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2712,6 +2712,13 @@ export class Lowerer { /** Each class's constructor return plan by class name * (constructor-returns.ts); null when no return can yield an object. */ readonly constructorReturnPlans = new Map(); + /** Pointer-guarded (`tdz`) module bindings declared without an + * initializer (`let f: T;`): an initialization flag tracks their + * declaration (markCycleEarlyBindings). */ + readonly uninitializedLexicalGlobals = new Map< + string, + { name: ts.Identifier; module: ts.SourceFile; symbol: ts.Symbol } + >(); /** CommonJS require declarations whose bindings code running before the * require can read, and those references (require-initialization.ts). */ readonly requireInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json new file mode 100644 index 000000000..f5706acaa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/let-uninitialized-function-binding.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/let-uninitialized-function-binding.ts" + ], + "diags": [] +} diff --git a/tests/corpus/let-uninitialized-function-binding.ts b/tests/corpus/let-uninitialized-function-binding.ts new file mode 100644 index 000000000..ade6748d3 --- /dev/null +++ b/tests/corpus/let-uninitialized-function-binding.ts @@ -0,0 +1,46 @@ +// A module-scope `let` declared without an initializer is initialized (to +// undefined) where the declaration runs, so a later assignment of a closure +// that calls the binding itself is an ordinary write. +type Step = (n: number) => number; + +// A write that runs before the declaration is still in the dead zone. +function early(): void { + late = () => 1; +} +try { + early(); +} catch (e) { + console.log((e as Error).name, (e as Error).message); +} +let late: () => number; +late = () => 2; +early(); +console.log(late()); + +let countdown: Step; +countdown = (n) => (n <= 0 ? 0 : 1 + countdown(n - 1)); +console.log(countdown(5)); + +let log: (s: string) => void; +log = (s) => { + if (s.length < 3) log(s + "!"); + else console.log(s); +}; +log("a"); + +// Reassignment and assignment from a function body after the declaration. +let pick: (xs: number[]) => number; +function install(largest: boolean): void { + pick = largest ? (xs) => Math.max(...xs) : (xs) => Math.min(...xs); +} +install(true); +console.log(pick!([3, 9, 4])); +install(false); +console.log(pick!([3, 9, 4])); + +// Mutual recursion through two such bindings. +let isEven: (n: number) => boolean; +let isOdd: (n: number) => boolean; +isEven = (n) => (n === 0 ? true : isOdd(n - 1)); +isOdd = (n) => (n === 0 ? false : isEven(n - 1)); +console.log(isEven(10), isOdd(7), isEven(3)); From 8a89024e7b8ef8e11c3e64cbf9552a7b1ad1858b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:34:31 -0700 Subject: [PATCH 020/127] Refuse module exports that read this when called through the module binding `m.f()` where `m` is `const m = require("./x")` or `import * as m from "./x"` lowers to a direct call of the exported function, so a function that reads `this` saw no receiver (or another frame's) where Node passes the module object: `exports.hello = function () { return this.name; }` threw a TypeError natively. Such calls now refuse with SC1090 (deferred in JavaScript); exports that do not read this compile as before. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 3 + .../frontend/lowering/module-member-this.ts | 64 +++++++++++++++++++ tests/harness/errors.test.ts | 49 ++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 packages/compiler/src/frontend/lowering/module-member-this.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..76240a7b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,4 +1,5 @@ import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; +import { refuseModuleMemberThis } from "./module-member-this.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { lowerHashStreamCall } from "./builtins/hash-stream.js"; import { lowerArrayCallbackCall } from "./containers/array-callback-call.js"; @@ -1188,6 +1189,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (facadeServed) return facadeServed; const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); if (memberSym) fenceEarlyNsMemberRef(lowerer, expr.expression, memberSym); + refuseModuleMemberThis(lowerer, expr, nsMember); const generic = lowerer.genericFnOf(nsMember); if (generic) return lowerer.lowerGenericCall(expr, generic); const sig = lowerer.fnSigOf(nsMember); @@ -1251,6 +1253,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (!ts.isIdentifier(nameId)) { lowerer.unsupported("SC1090", nameId, "private-named module members"); } + refuseModuleMemberThis(lowerer, expr, nameId); const generic = lowerer.genericFnOf(nameId); if (generic) return lowerer.lowerGenericCall(expr, generic); const sig = lowerer.fnSigOf(nameId); diff --git a/packages/compiler/src/frontend/lowering/module-member-this.ts b/packages/compiler/src/frontend/lowering/module-member-this.ts new file mode 100644 index 000000000..a29e361d9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/module-member-this.ts @@ -0,0 +1,64 @@ +/* A module export called through its module binding (`m.f()` where `m` is + * `const m = require("./x")` or `import * as m from "./x"`): Node passes + * the module object (the exports object, or the namespace) as `this`. The + * member call lowers to a direct call of the exported function, which has + * no module object to pass, so an export that reads `this` refuses instead + * of seeing another receiver. */ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +/** The function an export binding holds, when its declaration shows it. */ +function exportedFunction(decl: ts.Node): ts.Node | null { + let node: ts.Node | undefined = decl; + if (ts.isPropertyAccessExpression(node) && node.parent && ts.isBinaryExpression(node.parent)) + node = node.parent; + if (ts.isBinaryExpression(node)) node = node.right; + else if (ts.isVariableDeclaration(node) || ts.isPropertyAssignment(node)) node = node.initializer; + while (node !== undefined && ts.isParenthesizedExpression(node)) node = node.expression; + if (node === undefined) return null; + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isMethodDeclaration(node) + ) + return node; + return null; +} + +/** Whether a function's own body reads `this` (arrows share it; other + * functions and classes have their own). */ +function readsOwnThis(fn: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if (node.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return; + } + if ((ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLikeDeclaration(node)) + return; + node.forEachChild(visit); + }; + fn.forEachChild(visit); + return found; +} + +export function refuseModuleMemberThis( + lowerer: Lowerer, + call: ts.CallExpression, + member: ts.Identifier, +): void { + let symbol = lowerer.checker.getSymbolAtLocation(member); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + if (symbol === undefined) return; + for (const decl of lowerer.checker.declarationsOf(symbol)) { + const fn = exportedFunction(decl); + if (fn === null || !readsOwnThis(fn)) continue; + lowerer.unsupported( + "SC1090", + call, + `calling a module export that reads 'this' through its module binding ('${member.text}': Node passes the module object as 'this', which this call has no value for)`, + ); + } +} diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 856c1ad7d..d99192d20 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1065,3 +1065,52 @@ console.log(new Wrap({ a: 1 })); ); }); }); + +describe("module exports that read this, called through the module binding (JS lane)", () => { + test("a require binding's member call is a named deferred fence", async () => { + // Node passes the exports object as `this` to `m.hello()`; the direct + // call has none to pass, so it refuses instead of reading another one. + const r = await compileAndRun( + "require-member-this", + `'use strict'; +const m = require('./lib.cjs'); +console.log(m.plain(21)); +console.log(m.hello()); +`, + "cjs", + { + "lib.cjs": `'use strict'; +exports.name = 'lib'; +exports.hello = function () { return 'hi from ' + this.name; }; +exports.plain = function (x) { return x * 2; }; +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("42\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: calling a module export that reads 'this' through its module binding \('hello': .*\) is not supported yet \[SC1090 at .*require-member-this\.cjs:4\]\n$/, + ); + }); + + test("a namespace import's member call is a named deferred fence", async () => { + const r = await compileAndRun( + "namespace-member-this", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.who()); +`, + "mjs", + { + "lib.mjs": `export function who() { return typeof this; } +export function plain(x) { return x + 1; } +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("2\n"); + expect(r.stderr).toMatch( + /^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$/, + ); + }); +}); From 8ec0d9e0b9c573d85e7b8fbde3c4f5ea1258c0e1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:57:42 -0700 Subject: [PATCH 021/127] Link the emitWarning replacement and extended stdio only where used The process.emitWarning replacement and the stdin encoding, string data listener and stdio 'resize' support changed runtime functions every program with the dyn-async or events unit links (the shared warning paths, stdin delivery, the process stream object) and grew their thread-local state. That code now compiles only into new runtime-pack variants, SCR_WARNING_REPLACE of scr_async_dyn.c and SCR_STDIO_EXTENDED of scr_events.c, which the link selects when the program assigns process.emitWarning or reaches setEncoding, a string 'data' listener or 'resize'. The default objects are byte-identical to before, so other executables do not change. The development and sanitizer builds pass the same defines. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 2 + .../src/backend/native-link-info-core.ts | 6 ++ .../compiler/src/backend/native/contracts.ts | 4 + .../compiler/src/backend/native/executable.ts | 4 + .../compiler/src/backend/runtime-pack-core.ts | 4 + .../src/executable/executable-cache.ts | 2 + packages/compiler/src/index.ts | 2 + packages/compiler/src/ir/runtime-features.ts | 24 ++++++ .../compiler/src/native/sanitizer.test.ts | 2 + .../runtime-pack-matrix.mjs | 17 ++++ .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-matrix.mjs | 17 ++++ .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_async_dyn.c | 12 ++- packages/runtime/src/scr_events.c | 86 +++++++++++++++++-- 15 files changed, 176 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 553643470..7918cba20 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -14,6 +14,8 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { fileHandle: features.fileHandle, fetch: features.fetch, ...(features.eventTarget ? { eventTarget: true } : {}), + ...(features.warningReplace ? { warningReplace: true } : {}), + ...(features.stdioExtended ? { stdioExtended: true } : {}), zlib: features.zlib, assert: features.assert, inspect: features.inspect, diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 8f69cea57..47cba0606 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -22,6 +22,12 @@ export interface NativeLinkFeatures { fileHandle: boolean; fetch: boolean; eventTarget?: boolean; + /** The program assigns process.emitWarning: links the + * SCR_WARNING_REPLACE variant of the dyn-async unit. */ + warningReplace?: boolean; + /** The program uses process.stdin.setEncoding, string 'data' listeners + * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ + stdioExtended?: boolean; zlib: boolean; assert: boolean; inspect: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 5e981fe44..169f6231f 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -112,6 +112,10 @@ export interface CcOptions { * native fetch AbortSignal built on the same engine: compiles * scr_event_target.c. */ eventTarget?: boolean; + /** SCR_WARNING_REPLACE: process.emitWarning replacement support. */ + warningReplace?: boolean; + /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ + stdioExtended?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 549e9ebbf..786922ab4 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -445,6 +445,8 @@ export async function compileCInternal( : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), + ...(opts.warningReplace ? ["-DSCR_WARNING_REPLACE"] : []), + ...(opts.stdioExtended ? ["-DSCR_STDIO_EXTENDED"] : []), "-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 @@ -643,6 +645,8 @@ export async function compileCInternal( : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), + ...(opts.warningReplace ? ["-DSCR_WARNING_REPLACE"] : []), + ...(opts.stdioExtended ? ["-DSCR_STDIO_EXTENDED"] : []), "-fno-math-errno", "-fno-strict-aliasing", // the emitted object model type-puns — see buildArgs "-Wno-deprecated-declarations", diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 039ecc209..6ae14e866 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -81,6 +81,8 @@ export interface RuntimePackManifest { export interface RuntimeFeatureSet extends NativeLinkFeatures { workers: boolean; eventTarget: boolean; + warningReplace: boolean; + stdioExtended: boolean; nativeFetch: boolean; netEffective: boolean; httpEffective: boolean; @@ -267,6 +269,8 @@ export function effectiveRuntimeFeatures( ...features, workers: features.workers === true, eventTarget: features.eventTarget === true, + warningReplace: features.warningReplace === true, + stdioExtended: features.stdioExtended === true, nativeFetch, netEffective, httpEffective, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index fbd7e4edd..760eee25e 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -55,6 +55,8 @@ export interface EarlyExecutableNativeFeatures { fileHandle: boolean; fetch: boolean; eventTarget?: boolean; + warningReplace?: boolean; + stdioExtended?: boolean; zlib: boolean; assert: boolean; inspect: boolean; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 56b787e18..82d6a54dc 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -527,6 +527,8 @@ async function compileExecutableNative( fileHandle: features.fileHandle, fetch: features.fetch, ...(features.eventTarget ? { eventTarget: true } : {}), + ...(features.warningReplace ? { warningReplace: true } : {}), + ...(features.stdioExtended ? { stdioExtended: true } : {}), zlib: features.zlib, assert: features.assert, inspect: features.inspect, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index ccca73c7a..1bda9c64b 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -16,6 +16,10 @@ export interface RuntimeFeatures { fileHandle: boolean; fetch: boolean; eventTarget: boolean; + /** process.emitWarning is assigned (process.setEmitWarning). */ + warningReplace: boolean; + /** stdin setEncoding / string data listeners / stdio 'resize' events. */ + stdioExtended: boolean; processEvents: boolean; emitter: boolean; stream: boolean; @@ -201,6 +205,10 @@ 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, @@ -214,6 +222,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run (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)), @@ -279,6 +289,14 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run ) 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; // Signal-taking readline, stream and timers/promises entry points // register abort listeners. @@ -495,6 +513,12 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run 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; diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 9047f3376..cdde641d8 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -74,6 +74,8 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( features: { workers: false, eventTarget: false, + warningReplace: false, + stdioExtended: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 54e789624..42745aa7e 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -124,6 +124,23 @@ function variantsFor(source) { 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"], + }); + } return variants; } diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index d5c8c3720..e98e5cef7 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -343,7 +343,7 @@ async function build() { ...(unitHelper === null ? {} : { speed_codegen: unitHelper.identity }), }, macros: { - executable: ["SCR_TEXT_DECODER_LEGACY"], + executable: ["SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED"], excluded: ["SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required", }, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6b5052482..11712eb5c 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -114,6 +114,23 @@ function variantsFor(source) { 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"], + }); + } return variants; } diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index d1de57418..36ae930a1 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -440,7 +440,7 @@ async function build() { ...(unitHelper === null ? {} : { speed_codegen: unitHelper.identity }), }, macros: { - executable: ["SCR_TEXT_DECODER_LEGACY"], + executable: ["SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED"], excluded: ["SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required", }, diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 3e33a871b..e9287da21 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -860,8 +860,11 @@ ScrDyn *scr_warning_new(ScrStr *message, const char *type, const ScrStr *code) { return w; } +#ifdef SCR_WARNING_REPLACE /* The program's process.emitWarning replacement, or NULL while the - * property holds Node's own function. */ + * property holds Node's own function. Compiled only into the + * SCR_WARNING_REPLACE variant, which programs assigning process.emitWarning + * link: every other executable keeps the plain warning paths. */ static SCR_TL ScrDyn *scr_emit_warning_override = NULL; static void scr_emit_warning_override_teardown(void) { @@ -894,11 +897,13 @@ static void scr_emit_warning_replaced(ScrStr *message, ScrStr *type, ScrStr *cod scr_dyn_release(r); for (size_t i = 0; i < argc; i++) scr_dyn_release(args[i]); } +#endif /* SCR_WARNING_REPLACE */ /* A warning built from C parts (the runtime deprecation sites): name * defaults to "Warning"; code/detail optional. Reported synchronously. * Borrows message. */ void scr_emit_warning(const char *name, const char *code, ScrStr *message) { +#ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { ScrStr *type_text = scr_str_new(name ? name : "Warning", strlen(name ? name : "Warning")); ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; @@ -907,6 +912,7 @@ void scr_emit_warning(const char *name, const char *code, ScrStr *message) { scr_str_release(code_text); return; } +#endif ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; ScrDyn *w = scr_warning_new(message, name ? name : "Warning", code_text); scr_str_release(code_text); @@ -937,6 +943,7 @@ static void scr_warning_defer(ScrDyn *w, void (*fire)(ScrClosure *)) { } void scr_warning_emit_deferred(ScrDyn *w) { +#ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { const ScrDyn *em = scr_dyn_obj_get(w, "message", 7); const ScrDyn *ec = scr_dyn_obj_get(w, "code", 4); @@ -947,6 +954,7 @@ void scr_warning_emit_deferred(ScrDyn *w) { scr_str_release(name); return; } +#endif if (scr_warning_is_deprecation(w)) { if (scr_process_deprecation_flag_on(0)) return; if (scr_process_deprecation_flag_on(1)) { @@ -1033,11 +1041,13 @@ static void scr_warn_bad_arg(const char *arg, const char *want) { * ERR_INVALID_ARG_TYPE TypeErrors at the call; non-string details are * ignored. The warning itself is emitted on the next tick. */ void scr_process_emit_warning(ScrDyn *args) { +#ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { ScrDyn *r = scr_dyn_apply(scr_emit_warning_override, args, "process.emitWarning"); scr_dyn_release(r); return; } +#endif size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; ScrDyn *const *items = args->kind == SCR_DYN_ARR ? args->v.arr.items : NULL; ScrDyn *warning = argc >= 1 ? items[0] : NULL; diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 38116e8fe..3391f54b1 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -345,18 +345,23 @@ static void scr_signals_drain(void) { * listener it is treated as EOF (piped fd 0 read errors are not * meaningfully reachable). */ -/* What a 'data' listener receives: raw chunks (typed Buffer listeners and - * the runtime's own consumers), the decoded string (typed string - * listeners, which need setEncoding), or whichever Node delivers - * (checked-dynamic listeners: the string once an encoding is set, else - * the Buffer). */ +#ifdef SCR_STDIO_EXTENDED +/* What a 'data' listener receives (the SCR_STDIO_EXTENDED variant, linked + * by programs that use setEncoding, string data listeners or 'resize'): + * raw chunks (typed Buffer listeners and the runtime's own consumers), the + * decoded string (typed string listeners, which need setEncoding), or + * whichever Node delivers (checked-dynamic listeners: the string once an + * encoding is set, else the Buffer). */ enum { SCR_STDIN_DATA_BYTES, SCR_STDIN_DATA_STRING, SCR_STDIN_DATA_DYN }; +#endif typedef struct { ScrClosure *cb; /* owned */ void (*fn)(ScrClosure *cb, ScrBytes *chunk); /* adapter: chunk borrowed */ +#ifdef SCR_STDIO_EXTENDED void (*str_fn)(ScrClosure *cb, ScrStr *text); /* STRING/DYN: text borrowed */ unsigned char kind; +#endif bool once; } ScrStdinDataL; @@ -385,6 +390,8 @@ static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; static SCR_TL ScrBytes *scr_stdin_buffer; static SCR_TL size_t scr_stdin_read_demand; + +#ifdef SCR_STDIO_EXTENDED /* process.stdin.setEncoding(enc): the canonical encoding name (NULL until * set) and the StringDecoder's packed pending partial sequence, so a * multibyte character split across reads decodes whole, as Node's @@ -434,6 +441,24 @@ void scr_stdin_set_encoding(ScrStr *enc) { scr_str_release(previous); scr_stdin_dec_pending = 0; } +#else +void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), + bool once) { + if (scr_stdin_eof || scr_stdin_destroyed) { + scr_closure_release(cb); /* never fires again, like Node post-'end' */ + return; + } + if (scr_stdin_ndata == scr_stdin_data_cap) { + scr_stdin_data_cap = scr_stdin_data_cap ? scr_stdin_data_cap * 2 : 2; + scr_stdin_data = realloc(scr_stdin_data, scr_stdin_data_cap * sizeof *scr_stdin_data); + if (!scr_stdin_data) scr_events_oom(); + } + scr_stdin_data[scr_stdin_ndata].cb = cb; + scr_stdin_data[scr_stdin_ndata].fn = fn; + scr_stdin_data[scr_stdin_ndata].once = once; + scr_stdin_ndata++; +} +#endif void scr_stdin_on_end(ScrClosure *cb /*moves*/, bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { @@ -525,12 +550,14 @@ static void scr_stdin_settle_done(void) { scr_promise_release(p); } -static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text); - /* EOF/teardown shared tail: 'end' fires (snapshot; once semantics are * moot — everything drops right after), the parked promise settles done, * and every listener is released. */ +#ifdef SCR_STDIO_EXTENDED +static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text); +#endif static void scr_stdin_finish(bool fire_end) { +#ifdef SCR_STDIO_EXTENDED if (fire_end && scr_stdin_encoding != NULL && scr_stdin_ndata > 0) { /* The decoder's end() flush reaches the string listeners before 'end'. */ ScrStr *tail = scr_strdec_end(scr_stdin_encoding, scr_stdin_dec_pending); @@ -539,6 +566,7 @@ static void scr_stdin_finish(bool fire_end) { scr_str_release(tail); if (scr_exc_pending()) fire_end = false; } +#endif scr_stdin_eof = true; if (fire_end) { size_t n = scr_stdin_nend; @@ -708,6 +736,7 @@ static void scr_stdin_service(void) { scr_stdin_arrived(chunk); } +#ifdef SCR_STDIO_EXTENDED static void scr_stdin_deliver(ScrBytes *chunk) { if (scr_stdin_ndata > 0 && scr_stdin_encoding != NULL) { /* Node's readable decodes once, in the stream: every listener sees @@ -723,6 +752,9 @@ static void scr_stdin_deliver(ScrBytes *chunk) { } static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { +#else +static void scr_stdin_deliver(ScrBytes *chunk) { +#endif if (scr_stdin_ndata > 0) { size_t nd = scr_stdin_ndata; ScrStdinDataL *snap = malloc(nd * sizeof *snap); @@ -743,6 +775,7 @@ static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { } } } +#ifdef SCR_STDIO_EXTENDED if (!scr_exc_pending()) { if (snap[i].kind == SCR_STDIN_DATA_BYTES) { if (chunk != NULL) snap[i].fn(snap[i].cb, chunk); @@ -760,6 +793,13 @@ static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { } free(snap); } else if (scr_stdin_waiter && chunk != NULL) { +#else + if (!scr_exc_pending()) snap[i].fn(snap[i].cb, chunk); + scr_closure_release(snap[i].cb); + } + free(snap); + } else if (scr_stdin_waiter) { +#endif ScrPromise *p = scr_stdin_waiter; scr_stdin_waiter = NULL; scr_promise_fulfill_ref(p, scr_bytes_retain(chunk), scr_bytes_retain_v, @@ -778,14 +818,17 @@ typedef struct { bool *error_once; size_t nerrors; ScrDyn *write_error; +#ifdef SCR_STDIO_EXTENDED /* 'resize' listeners (stdout/stderr) and the size they last saw. */ ScrDyn **resizes; bool *resize_once; size_t nresizes; double columns, rows; +#endif } ScrStdio; static SCR_TL ScrStdio scr_stdio_streams[3] = {{.fd = 0}, {.fd = 1}, {.fd = 2}}; +#ifdef SCR_STDIO_EXTENDED enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_SET_ENCODING, STDIO_METHOD_COUNT }; @@ -793,6 +836,13 @@ static const char *const scr_stdio_names[] = { "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize", "setEncoding" }; +#else +enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, + STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_METHOD_COUNT }; +static const char *const scr_stdio_names[] = { + "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize" +}; +#endif static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; static SCR_TL bool scr_stdio_raw; @@ -820,11 +870,13 @@ static void scr_stdio_data(ScrClosure *cb, ScrBytes *bytes) { scr_stdio_fire(cb, chunk, 1); scr_dyn_release(chunk); } +#ifdef SCR_STDIO_EXTENDED static void scr_stdio_data_text(ScrClosure *cb, ScrStr *text) { ScrDyn *chunk = scr_dyn_new_str(text); scr_stdio_fire(cb, chunk, 1); scr_dyn_release(chunk); } +#endif static void scr_stdio_end(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } static void scr_stdio_error(ScrClosure *cb, ScrStr *message) { ScrError *error = scr_error_new(SCR_ERR_ERROR, message); @@ -950,6 +1002,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { return result; } +#ifdef SCR_STDIO_EXTENDED /* 'resize' on a TTY stdout/stderr: Node's tty.WriteStream listens for * SIGWINCH and emits 'resize' when the window size changed. The signal * listener does not keep the loop alive, and a non-TTY stream never @@ -1037,6 +1090,7 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r #endif } +#endif static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); ScrDyn *self = scr_dyn_this_get(); @@ -1096,13 +1150,18 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc bool data = arg->kind == SCR_DYN_STR && arg->v.str->len == 4 && memcmp(event, "data", 4) == 0; bool end = arg->kind == SCR_DYN_STR && arg->v.str->len == 3 && memcmp(event, "end", 3) == 0; bool error = arg->kind == SCR_DYN_STR && arg->v.str->len == 5 && memcmp(event, "error", 5) == 0; +#ifdef SCR_STDIO_EXTENDED bool resize = stream->fd != 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 6 && memcmp(event, "resize", 6) == 0; if ((!data && !end && !error && !resize) || (stream->fd != 0 && !error && !resize)) { +#else + if ((!data && !end && !error) || (stream->fd != 0 && !error)) { +#endif result = scr_stdio_refusal("event"); goto done; } bool remove = method == STDIO_OFF, once = method == STDIO_ONCE; +#ifdef SCR_STDIO_EXTENDED if (resize) { scr_stdio_resize_listener(stream, listener, remove, once); } else if (stream->fd == 0) { @@ -1110,6 +1169,11 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc if (data) scr_stdin_add_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, scr_stdio_data_text, SCR_STDIN_DATA_DYN, once); +#else + if (stream->fd == 0) { + if (!remove) { + if (data) scr_stdin_on_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, once); +#endif if (end) scr_stdin_on_end(scr_stdio_callback(listener, (void *)scr_stdio_end), once); if (error) scr_stdin_on_error(scr_stdio_callback(listener, (void *)scr_stdio_error), scr_stdio_error, once); } else { @@ -1154,6 +1218,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } else if (stream->fd == 0 && method == STDIO_RAW) { scr_process_stdin_set_raw_mode(scr_dyn_truthy(arg)); if (!scr_exc_pending()) { scr_stdio_raw = scr_dyn_truthy(arg); result = scr_dyn_retain(self); } +#ifdef SCR_STDIO_EXTENDED } else if (stream->fd == 0 && method == STDIO_SET_ENCODING) { ScrStr *enc = scr_strdec_encoding(arg); if (enc) { @@ -1161,6 +1226,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_str_release(enc); result = scr_dyn_retain(self); } +#endif } else if (stream->fd == 0 && method == STDIO_DESTROY && argc == 0) { scr_stdin_destroy(); result = scr_dyn_retain(self); @@ -1258,12 +1324,16 @@ static void scr_stdio_cleanup(void) { for (size_t j = 0; j < stream->nerrors; j++) scr_dyn_release(stream->errors[j]); free(stream->errors); free(stream->error_once); stream->errors = NULL; stream->error_once = NULL; stream->nerrors = 0; +#ifdef SCR_STDIO_EXTENDED for (size_t j = 0; j < stream->nresizes; j++) scr_dyn_release(stream->resizes[j]); free(stream->resizes); free(stream->resize_once); stream->resizes = NULL; stream->resize_once = NULL; stream->nresizes = 0; +#endif } +#ifdef SCR_STDIO_EXTENDED scr_dyn_release(scr_stdio_winch); scr_stdio_winch = NULL; +#endif for (int i = 0; i < STDIO_METHOD_COUNT; i++) { scr_dyn_release(scr_stdio_methods[i]); scr_stdio_methods[i] = NULL; @@ -1407,9 +1477,11 @@ void scr_stdin_data_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { /* The listener owns its +1 param per the universal convention. */ ((void (*)(ScrClosure *, ScrBytes *))cb->fn)(cb, scr_bytes_retain(chunk)); } +#ifdef SCR_STDIO_EXTENDED void scr_stdin_data_thunk_str(ScrClosure *cb, ScrStr *text) { ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, scr_str_retain(text)); } +#endif /* ── the loop hooks (see scr_async.c) ─────────────────────────────────── */ From a1576d7c5f755be8847d3ddb29f851bfeefe16b5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:10:05 -0700 Subject: [PATCH 022/127] Keep the deferred-class-receiver fixture on a rejected field type The fixture's class was rejected through a WeakMap-typed field, which this branch makes legal. A MapIterator-typed field is still refused, so the class stays rejected and the test still checks that a class reached only through a receiver probe reports its own diagnostic exactly once. Co-Authored-By: Claude Opus 5.5 --- tests/diagnostics/deferred-class-receiver/shelf.ts | 2 +- .../__snapshots__/deferred-class-receiver/main.ts.txt | 6 +++--- tests/harness/coverage.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/diagnostics/deferred-class-receiver/shelf.ts b/tests/diagnostics/deferred-class-receiver/shelf.ts index 1308ce5dc..d0fba73b3 100644 --- a/tests/diagnostics/deferred-class-receiver/shelf.ts +++ b/tests/diagnostics/deferred-class-receiver/shelf.ts @@ -1,6 +1,6 @@ export class Shelf { readonly name: string; - private readonly notes = new WeakMap(); + private readonly cursor = new Map().keys(); constructor(name: string) { this.name = name; } diff --git a/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt b/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt index 0e9cab090..909795174 100644 --- a/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt +++ b/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt @@ -5,9 +5,9 @@ deferred-class-receiver/main.ts:4:10 - error SC1090: constructing through a clas | ^~~~~~~~~~~~~~~ 5 | } -deferred-class-receiver/shelf.ts:3:20 - error SC1090: 'WeakMap'-typed class fields are not supported yet +deferred-class-receiver/shelf.ts:3:20 - error SC1090: 'MapIterator'-typed class fields are not supported yet 2 | readonly name: string; - 3 | private readonly notes = new WeakMap(); - | ^~~~~ + 3 | private readonly cursor = new Map().keys(); + | ^~~~~~ 4 | constructor(name: string) { \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 420651418..a120bcc3a 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -391,7 +391,7 @@ test("a rejected class reached only through a receiver probe reports its own dia const { coverage } = analyze(join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts")); const messages = coverage.diagnostics.map((d) => d.message); expect( - messages.filter((m) => m.startsWith("'WeakMap'-typed class fields")), + messages.filter((m) => m.startsWith("'MapIterator'-typed class fields")), ).toHaveLength(1); expect(messages.some((m) => m.startsWith("constructing through a class value"))).toBe(true); expect(coverage.unreached?.diagnostics ?? []).toEqual([]); From 4e72477c55918a5976a58d0181895c9473cdefa0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 16:12:12 -0700 Subject: [PATCH 023/127] =?UTF-8?q?Answer=20NaN=20for=20**=20with=20a=20ba?= =?UTF-8?q?se=20of=20=C2=B11=20and=20a=20non-finite=20exponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `1 ** x` with x = Infinity printed 1 where Node prints NaN: the operator lowered to libm's pow, which answers 1 for pow(±1, ±Infinity) and pow(1, NaN) (and LLVM folds pow(1.0, x) to 1.0). Such operands now keep the direct pow call behind an inline guard that selects NaN when the base is ±1 and the exponent is not finite. A finite literal exponent (x ** 2) or a literal base other than ±1 (2 ** k) cannot meet those cases and keeps the bare call. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/llvm/expr-primitives.ts | 40 ++++++++++++++++++- .../tests/corpus/exponent-unit-base.ts.json | 6 +++ tests/corpus/exponent-unit-base.ts | 16 ++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json create mode 100644 tests/corpus/exponent-unit-base.ts diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index e237f8f13..dc2ba0112 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -379,7 +379,29 @@ export function emitOperatorExpr( const fn = libm[e.op]; if (fn === undefined) throw new LlvmUnsupportedError(`bin:${e.op}`, e.loc); host.declare(`declare double @${fn}(double, double)`); - B.line(`${t} = call double @${fn}(double ${l.name}, double ${r.name})`); + // libm's pow answers 1 for pow(±1, ±Infinity) and pow(1, NaN), where + // `**` answers NaN (and LLVM folds pow(1.0, x) to 1.0): an inline + // guard selects NaN for a base of ±1 with a non-finite exponent and + // keeps the direct pow call. A finite literal exponent, or a literal + // base other than ±1, cannot meet those cases and needs no guard. + if (fn === "pow" && !libmPowAgrees(e.left, e.right)) { + host.declare(`declare double @llvm.fabs.f64(double)`); + const power = B.tmp(); + B.line(`${power} = call double @pow(double ${l.name}, double ${r.name})`); + const base = B.tmp(); + const unit = B.tmp(); + const magnitude = B.tmp(); + const finite = B.tmp(); + const reject = B.tmp(); + B.line(`${base} = call double @llvm.fabs.f64(double ${l.name})`); + B.line(`${unit} = fcmp oeq double ${base}, 1.0`); + B.line(`${magnitude} = call double @llvm.fabs.f64(double ${r.name})`); + B.line(`${finite} = fcmp olt double ${magnitude}, 0x7FF0000000000000`); + B.line(`${reject} = select i1 ${finite}, i1 false, i1 ${unit}`); + B.line(`${t} = select i1 ${reject}, double 0x7FF8000000000000, double ${power}`); + } else { + B.line(`${t} = call double @${fn}(double ${l.name}, double ${r.name})`); + } } return { name: t, type: e.type }; } @@ -1103,3 +1125,19 @@ export function emitRecordLitFields( host.initField(ptr, type, inline, v); } } + +/** Whether libm pow matches `**` for these operands: every case except a + * base of ±1 with a NaN or infinite exponent agrees. */ +function libmPowAgrees(base: IrExpr, exponent: IrExpr): boolean { + const power = numericLiteral(exponent); + if (power !== null && Number.isFinite(power)) return true; + const value = numericLiteral(base); + return value !== null && !Number.isNaN(value) && Math.abs(value) !== 1; +} + +function numericLiteral(expr: IrExpr): number | null { + if (expr.kind === "numLit") return expr.value; + if (expr.kind === "unary" && expr.op === "-" && expr.operand.kind === "numLit") + return -expr.operand.value; + return null; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json new file mode 100644 index 000000000..b8de10f6a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/exponent-unit-base.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/exponent-unit-base.ts" + ], + "diags": [] +} diff --git a/tests/corpus/exponent-unit-base.ts b/tests/corpus/exponent-unit-base.ts new file mode 100644 index 000000000..d8d4cd1ac --- /dev/null +++ b/tests/corpus/exponent-unit-base.ts @@ -0,0 +1,16 @@ +// `**` answers NaN for a base of ±1 with a NaN or infinite exponent, where +// libm's pow (and LLVM's folding of pow(1, x)) answers 1. +const values = [Infinity, -Infinity, NaN, 0, -0, 2, 0.5]; +const read = (i: number): number => values[i % values.length]!; +for (let i = 0; i < values.length; i++) { + const x = read(i); + console.log(x, 1 ** x, (-1) ** x, x ** 0, x ** 1, 2 ** x, x ** -1); +} +let base = 1; +let exponent = Infinity; +console.log(base ** exponent, (-base) ** exponent, base ** NaN); +exponent = 3; +console.log(base ** exponent, (-1) ** exponent); +let acc = 1; +acc **= Infinity; +console.log(acc, Math.pow(1, Infinity), NaN ** 0, (-0) ** -1, 0 ** -1); From 273c20c136cb5069d508668dd25e25b7508bdf30 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:37:53 -0700 Subject: [PATCH 024/127] Bump the IR version in main's new stack-observations test Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/stack-observations.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/stack-observations.test.ts b/packages/compiler/src/backend/stack-observations.test.ts index f58c89ad8..a95f0140f 100644 --- a/packages/compiler/src/backend/stack-observations.test.ts +++ b/packages/compiler/src/backend/stack-observations.test.ts @@ -34,7 +34,7 @@ const fn = (name: string, body: IrStmt[]): IrFunction => ({ body, }); const mod = (functions: IrFunction[], extra: Partial = {}): IrModule => ({ - irVersion: 16, + irVersion: 17, sourceFile: "stack-observations.ts", entry: "%main", functions, From 408c63baa2cf0207bb355c03e7836a1e8469b051 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:58:04 -0700 Subject: [PATCH 025/127] Declare the new runtime features in the scanner's literal order JSON.stringify of the runtime-features record follows the declared field order in the self-hosted compiler and the literal's insertion order under Node. warningReplace and stdioExtended were declared before processEvents but built after it, so the two compilers serialized the record differently (self-hosting-runtime-features). The interface now lists them in the literal's order. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/ir/runtime-features.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 1bda9c64b..d337546bd 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -16,11 +16,13 @@ export interface RuntimeFeatures { fileHandle: boolean; fetch: boolean; eventTarget: boolean; - /** process.emitWarning is assigned (process.setEmitWarning). */ + 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; - processEvents: boolean; emitter: boolean; stream: boolean; zlib: boolean; From f79ed581383e3d1fb9d7fd23d03de774b73e850d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 18:24:13 -0700 Subject: [PATCH 026/127] Link the runtime units of lowered IR as compile() does in the harnesses The self-hosting harnesses link the IR they lower through compileC. Most hand-picked a subset of runtime feature flags, which lacked dynTier: a sanitized build compiles exactly the listed runtime sources, so programs with jsval operations failed to link scr_js_value.c's entry points, and harnesses passing no flags at all failed in both lanes. Every such site now spreads moduleLinkFeatures (tests/harness/ link-features.ts), which is executableLinkFeatures, the selection compile() uses, so a runtime unit added later cannot drift out of the harness builds. moduleUsesDynTier joins the IR facade's unit gates. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/ir/api.ts | 1 + tests/harness/link-features.ts | 7 +++++++ tests/harness/native-frontend.ts | 21 +++---------------- .../self-hosting-backend-analysis.test.ts | 12 ++--------- tests/harness/self-hosting-emission.test.ts | 12 ++--------- .../harness/self-hosting-llvm-emitter.test.ts | 6 +++--- .../self-hosting-serialization.test.ts | 8 ++++++- .../self-hosting-union-conversions.test.ts | 9 +++++++- tests/harness/self-hosting.test.ts | 2 ++ 9 files changed, 35 insertions(+), 43 deletions(-) create mode 100644 tests/harness/link-features.ts diff --git a/packages/compiler/src/ir/api.ts b/packages/compiler/src/ir/api.ts index f61bb1054..600cdb051 100644 --- a/packages/compiler/src/ir/api.ts +++ b/packages/compiler/src/ir/api.ts @@ -18,6 +18,7 @@ export { moduleUsesAssert, moduleUsesDynInvoke, moduleUsesDynAsync, + moduleUsesDynTier, moduleUsesInspect, moduleUsesChildProcess, moduleUsesNet, diff --git a/tests/harness/link-features.ts b/tests/harness/link-features.ts new file mode 100644 index 000000000..c62eeaa64 --- /dev/null +++ b/tests/harness/link-features.ts @@ -0,0 +1,7 @@ +/* The runtime units a lowered module links, selected exactly as compile() + * selects them (executableLinkFeatures). Harnesses that link IR through + * compileC spread this instead of hand-picking feature flags: a hand-picked + * list silently misses a unit added later (the dynamic tier's + * scr_js_value.c), which the sanitized lane, compiling only the listed + * runtime sources, reports as undefined references. */ +export { executableLinkFeatures as moduleLinkFeatures } from "../../packages/compiler/src/backend/executable-features.js"; diff --git a/tests/harness/native-frontend.ts b/tests/harness/native-frontend.ts index 475db54aa..c0bb5b2be 100644 --- a/tests/harness/native-frontend.ts +++ b/tests/harness/native-frontend.ts @@ -15,16 +15,7 @@ import type { compile } from "@scriptc/compiler"; import type { IrModule } from "../../packages/compiler/src/ir/ir.js"; import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js"; -import { - moduleUsesAssert, - moduleUsesBigInt, - moduleUsesCopying, - moduleUsesDynInvoke, - moduleUsesInspect, - moduleUsesRegex, - moduleUsesSymbol, - moduleUsesZlib, -} from "../../packages/compiler/src/ir/runtime-features.js"; +import { moduleLinkFeatures } from "./link-features.js"; const root = join(import.meta.dirname, "../.."); const entry = join(root, "tests/fixtures/self-hosting/frontend-pipeline.ts"); @@ -220,14 +211,7 @@ export async function checkProgram(frontend: NativeFrontend, file: string): Prom cPath, outPath: frontend.executable("program"), sanitize, - regex: moduleUsesRegex(module), - copying: moduleUsesCopying(module), - inspect: moduleUsesInspect(module), - dynInvoke: moduleUsesDynInvoke(module), - symbol: moduleUsesSymbol(module), - bigint: moduleUsesBigInt(module), - zlib: moduleUsesZlib(module), - assert: moduleUsesAssert(module), + ...moduleLinkFeatures(module), }); const node = spawnSync(process.execPath, [source], runOptions); const native = spawnSync(frontend.executable("program"), [], runOptions); @@ -390,6 +374,7 @@ export async function checkNativeBindings(frontend: NativeFrontend): Promise fn.name.endsWith("retag"))).toHaveLength(5); const cPath = join(dir, "generated.ll"); writeFileSync(cPath, emitLlvmModule(module)); - await compileC({ cPath, outPath: exe("generated"), sanitize, optimization: "dev" }); + await compileC({ + cPath, + outPath: exe("generated"), + sanitize, + optimization: "dev", + ...moduleLinkFeatures(module), + }); const generated = spawnSync(exe("generated"), [], runOptions); expect(generated.error).toBeUndefined(); expect(generated.signal, generated.stderr).toBeNull(); diff --git a/tests/harness/self-hosting.test.ts b/tests/harness/self-hosting.test.ts index adf67807c..7198e7176 100644 --- a/tests/harness/self-hosting.test.ts +++ b/tests/harness/self-hosting.test.ts @@ -12,6 +12,7 @@ import { emitLlvmModule, validateModule, } from "@scriptc/compiler"; +import { moduleLinkFeatures } from "./link-features.js"; const root = fileURLToPath(new URL("../..", import.meta.url)); @@ -137,6 +138,7 @@ for (const [fixture, backend] of ["ir-build", "contextual-ir", "coercion-builder outPath, sanitize, cacheIdentity: "scriptc-generated-v1", + ...moduleLinkFeatures(mod), }); const program = spawnSync(outPath, [], options); expect(program.error).toBeUndefined(); From 907d481a1d55380ea1099f162f4b5f767e629728 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 21:31:43 -0500 Subject: [PATCH 027/127] Refuse super() into a constructor that returns an instance only when it returns another one A base constructor that returns an instance of its class gets a result slot, and super() from a subclass refused. three.js's Color constructor ends in `return this.set(r, g, b)`, where set returns this, so every construction of Color4 (`super(r, g, b)` during three.webgpu.js's module initialization) threw the refusal, while Node constructs the subclass's own instance. In JavaScript, super() into such a class now calls a per-subclass helper after the base constructor: it reads and clears the base's result slot, and refuses at run time only when the base returned an instance other than the subclass's this, where Node would rebind it. TypeScript subclasses keep the compile-time refusal. --- .../frontend/lowering/constructor-returns.ts | 93 +++++++++++++++++-- .../src/frontend/lowering/lower-classes.ts | 13 ++- .../constructor-returns-this-super.js.json | 6 ++ .../corpus/constructor-returns-this-super.js | 33 +++++++ tests/harness/corpus-costs.json | 2 + 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json create mode 100644 tests/corpus/constructor-returns-this-super.js diff --git a/packages/compiler/src/frontend/lowering/constructor-returns.ts b/packages/compiler/src/frontend/lowering/constructor-returns.ts index d054a6b49..950ace57c 100644 --- a/packages/compiler/src/frontend/lowering/constructor-returns.ts +++ b/packages/compiler/src/frontend/lowering/constructor-returns.ts @@ -17,7 +17,10 @@ * object refuses. * * `super()` from a subclass refuses: Node would rebind the subclass's - * `this` to the returned instance. */ + * `this` to the returned instance. In JavaScript the refusal is a run-time + * test after the call: an instance other than the subclass's own `this` + * refuses, and the constructed instance itself (`return this.set(r, g, + * b)`, where `set` answers `this`) continues. */ import * as ts from "../ts7/adapter.js"; import { varRef } from "../../ir/build.js"; import { @@ -29,6 +32,7 @@ import { type IrType, STRING, typeKey, + VOID, } from "../../ir/ir.js"; import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; import { isJsSourceFile, locOf } from "../program.js"; @@ -363,15 +367,84 @@ export function constructedFromValue( return constructedValue(lowerer, info, constructed); } -/** `super()` into a class with a result slot: Node rebinds the subclass's - * `this` to the returned instance, which has no representation. */ -export function refuseSuperIntoResultSlot(lowerer: Lowerer, base: ClassInfo, blame: ts.Node): void { +/** `super()` into a class with a result slot, from the constructor of + * class `sub`: `call` runs the base constructor over `self` (the + * subclass's `this`, upcast to the base). Node rebinds the subclass's + * `this` to an instance the base constructor returns, which has no + * representation. A TypeScript subclass refuses. In JavaScript the base + * stores the instance it returns in its slot, and a per-subclass helper + * reads and clears the slot after the call: an instance other than `self` + * refuses at run time. Unchanged for bases without a result slot. */ +export function superIntoResultSlot( + lowerer: Lowerer, + base: ClassInfo, + sub: ClassInfo, + call: IrStmt, + self: IrExpr, + blame: ts.Node, +): IrStmt { const plan = constructorReturnPlan(lowerer, base); - if (plan === null || plan.slot === null) return; + if (plan === null || plan.slot === null) return call; + const slot = plan.slot; const display = base.def.name.replace(/^%|^%m\d+\./, ""); - lowerer.unsupported( - "SC1090", - blame, - `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`, - ); + const feature = `extending a class whose constructor can return an existing instance ('super()' would rebind 'this' to the instance '${display}' returns)`; + if (!isJsSourceFile(blame.getSourceFile())) { + lowerer.unsupported("SC1090", blame, feature); + return call; + } + const loc = locOf(blame); + const type = instanceType(base); + const name = `%${sub.def.name}.%superResult`; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const diag = unsupportedDiag("SC1090", loc, feature); + lowerer.runtimeFences.push(diag); + const line = ts.getLineAndCharacterOfPosition(blame.getSourceFile(), loc.start).line + 1; + const instance = { id: "instance.0", name: "instance", type, mutable: false }; + const returned = { id: "returned.0", name: "returned", type: slot.type, mutable: false }; + lowerer.liftedFns.push({ + name, + params: [{ localId: instance.id, name: instance.name, type }], + returnType: VOID, + locals: [instance, returned], + body: [ + { kind: "varDecl", localId: returned.id, init: varRef(slot.id, slot.type, loc), loc }, + { kind: "assign", localId: slot.id, value: lowerer.wrappedUndefined(slot.type, loc)!, loc }, + { + kind: "if", + cond: { + kind: "bin", + op: "!==", + left: { + kind: "orDefault", + left: varRef(returned.id, slot.type, loc), + right: varRef(instance.id, type, loc), + type, + loc, + }, + right: varRef(instance.id, type, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "runtimeFence", + code: diag.code, + message: `${diag.message} [${diag.code} at ${loc.file}:${line}]`, + loc, + }, + ], + else_: null, + loc, + }, + { kind: "return", value: null, loc }, + ], + loc, + }); + } + const check: IrStmt = { + kind: "exprStmt", + expr: { kind: "call", callee: name, args: [self], type: VOID, loc }, + loc, + }; + return { kind: "block", body: [call, check], loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 568308b47..4f33bdc69 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -3,7 +3,7 @@ import { dynUndefinedExpr, nodeThrowExpr, varRef } from "../../ir/build.js"; import { constructedFromValue, constructedValue, - refuseSuperIntoResultSlot, + superIntoResultSlot, } from "./constructor-returns.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { lowerReadlineUiNew } from "./builtins/readline.js"; @@ -8032,9 +8032,8 @@ export function superCallStmt( info.decl ?? info.ctor!, )[0]!; } - refuseSuperIntoResultSlot(lowerer, base, info.ctor ?? info.decl!); lowerer.noteSuperConstructorEdge(`%${base.def.name}.constructor`); - return { + const call: IrStmt = { kind: "exprStmt", expr: { kind: "call", @@ -8045,6 +8044,14 @@ export function superCallStmt( }, loc, }; + return superIntoResultSlot( + lowerer, + base, + info, + call, + lowerer.upcastTo({ ...thisRef }, base.def.name), + info.ctor ?? info.decl!, + ); } /** `super.method(args)`: the base chain's implementation, called diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json new file mode 100644 index 000000000..fa81da713 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/constructor-returns-this-super.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/constructor-returns-this-super.js" + ], + "diags": [] +} diff --git a/tests/corpus/constructor-returns-this-super.js b/tests/corpus/constructor-returns-this-super.js new file mode 100644 index 000000000..3901c5553 --- /dev/null +++ b/tests/corpus/constructor-returns-this-super.js @@ -0,0 +1,33 @@ +// A JavaScript base constructor that returns `this` through a method, as +// three.js's Color does: `super()` from a subclass keeps the subclass's own +// instance. +class Color { + constructor(r, g, b) { + this.r = 1; + this.g = 1; + this.b = 1; + return this.set(r, g, b); + } + set(r, g, b) { + if (g === undefined) { + this.r = this.g = this.b = r; + } else { + this.r = r; + this.g = g; + this.b = b; + } + return this; + } +} +class Color4 extends Color { + constructor(r, g, b, a = 1) { + super(r, g, b); + this.a = a; + } +} +const c = new Color(0.5); +const c4 = new Color4(0.1, 0.2, 0.3); +const c4b = new Color4(1, 0, 0, 0.5); +console.log(c.r, c.g, c.b, c instanceof Color); +console.log(c4.r, c4.g, c4.b, c4.a, c4 instanceof Color4, c4 instanceof Color); +console.log(c4b.a, c4b !== c4, Object.keys(c4b).join(",")); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 1ba71d05f..aa9621ecc 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2485,6 +2485,7 @@ "constant-module-globals/main.ts": 2455, "constant-number-remainders.ts": 2350, "constructed-class-dispatch.ts": 3014, + "constructor-returns-this-super.js": 2431, "contextual-array-union-write.ts": 5379, "contextual-return-layouts.ts": 2919, "counted-loop-numbers.ts": 2515, @@ -5541,6 +5542,7 @@ "constant-module-globals/main.ts": 2911, "constant-number-remainders.ts": 2372, "constructed-class-dispatch.ts": 3382, + "constructor-returns-this-super.js": 4629, "contextual-array-union-write.ts": 6417, "contextual-return-layouts.ts": 3746, "counted-loop-numbers.ts": 2724, From 9f2e9b00c90b879eef27b4bc66d79c9464decdb9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 00:11:31 -0500 Subject: [PATCH 028/127] Write the limitations partials one sentence per line Each limitations partial held its whole text on one line (map-and-set.mdx was a single 1,080-character line), so any two edits to the same partial conflicted. The partials now put one sentence per line, with a bullet's continuation lines indented two spaces. Markdown joins the lines, so the rendered pages are unchanged. scripts/limitations-partials.mjs reads a labeled bullet's indented continuation lines as part of the bullet when it splits and joins the page, and AGENTS.md states the convention. --- AGENTS.md | 1 + .../array-bounds-and-runtime-errors.mdx | 17 +++++-- .../content/limitations/browser-embedding.mdx | 7 ++- .../limitations/buffer-construction.mdx | 4 +- docs/content/limitations/builtin-apis.mdx | 4 +- .../arraybuffers-and-typed-arrays.mdx | 10 +++- .../limitations/builtin-apis/assertions.mdx | 13 ++++- .../builtin-apis/blob-and-file.mdx | 5 +- .../builtin-apis/builtin-module-lookup.mdx | 6 ++- .../builtin-apis/commonjs-metadata.mdx | 4 +- .../limitations/builtin-apis/compression.mdx | 8 +++- .../content/limitations/builtin-apis/copy.mdx | 6 ++- .../limitations/builtin-apis/crypto.mdx | 17 ++++++- .../content/limitations/builtin-apis/date.mdx | 6 ++- .../builtin-apis/deprecated-modules.mdx | 5 +- .../limitations/builtin-apis/esm-metadata.mdx | 5 +- .../limitations/builtin-apis/eventemitter.mdx | 5 +- .../limitations/builtin-apis/exit-status.mdx | 4 +- .../limitations/builtin-apis/file-streams.mdx | 5 +- .../filehandle-and-descriptor-i-o.mdx | 8 +++- .../builtin-apis/filesystem-flags.mdx | 5 +- .../content/limitations/builtin-apis/glob.mdx | 5 +- .../limitations/builtin-apis/globals.mdx | 3 +- .../builtin-apis/intl-segmenter.mdx | 3 +- .../builtin-apis/iterator-helpers.mdx | 8 +++- .../builtin-apis/legacy-url-api.mdx | 4 +- .../limitations/builtin-apis/map-and-set.mdx | 11 ++++- .../builtin-apis/object-freezing.mdx | 4 +- .../builtin-apis/performance-entries.mdx | 3 +- .../builtin-apis/process-memory.mdx | 3 +- .../limitations/builtin-apis/prototypes.mdx | 3 +- .../builtin-apis/regular-expressions.mdx | 10 +++- .../builtin-apis/streams-and-readline.mdx | 25 +++++++++- .../builtin-apis/synchronous-iteration.mdx | 5 +- .../limitations/builtin-apis/test-mocks.mdx | 16 ++++++- .../limitations/builtin-apis/test-runner.mdx | 29 ++++++++--- .../builtin-apis/text-encoding.mdx | 8 +++- docs/content/limitations/builtin-apis/url.mdx | 10 +++- .../limitations/builtin-function-values.mdx | 28 ++++++++--- .../limitations/child-process-workers.mdx | 15 ++++-- .../console-and-process-streams.mdx | 13 ++++- docs/content/limitations/contract-types.mdx | 8 +++- .../limitations/dates-and-timezones.mdx | 7 ++- .../content/limitations/dns-lookup-errors.mdx | 4 +- .../limitations/foreign-thread-callbacks.mdx | 7 ++- docs/content/limitations/http-headers.mdx | 4 +- docs/content/limitations/introduction.mdx | 9 +++- docs/content/limitations/json-callbacks.mdx | 13 +++-- .../language-and-api-restrictions.mdx | 3 +- .../language-features/argument-spreads.mdx | 6 ++- .../language-features/async-iteration.mdx | 7 ++- .../language-features/class-reflection.mdx | 5 +- .../language-features/commonjs-require.mdx | 10 +++- .../language-features/dynamic-imports.mdx | 4 +- .../language-features/error-causes.mdx | 7 ++- .../language-features/function-values.mdx | 5 +- .../language-features/generics.mdx | 6 ++- .../language-features/loose-equality.mdx | 4 +- .../language-features/method-values.mdx | 6 ++- .../language-features/resource-disposal.mdx | 8 +++- docs/content/limitations/native-addons.mdx | 7 ++- docs/content/limitations/native-debugging.mdx | 10 +++- .../limitations/native-worker-threads.mdx | 48 +++++++++++++++++-- .../process-arguments-and-errors.mdx | 12 +++-- docs/content/limitations/process-events.mdx | 10 +++- docs/content/limitations/published-graphs.mdx | 10 ++-- .../record-identity-and-property-order.mdx | 25 ++++++++-- .../limitations/runtime-code-evaluation.mdx | 5 +- .../runtime-differences-from-node-js.mdx | 3 +- .../limitations/runtime-type-checks.mdx | 14 ++++-- .../limitations/socket-data-encoding.mdx | 4 +- .../limitations/strings-and-sorting.mdx | 18 +++++-- docs/content/limitations/three-js.mdx | 12 +++-- docs/content/limitations/tls-ciphers.mdx | 5 +- .../limitations/tooling-and-native-ffi.mdx | 9 +++- docs/content/limitations/type-checking.mdx | 7 ++- .../types-and-class-definitions.mdx | 4 +- .../types-and-class-definitions/bigint.mdx | 5 +- .../constructor-function-inheritance.mdx | 4 +- .../interfaces.mdx | 8 +++- .../local-class-expressions.mdx | 6 ++- .../null-and-optional-values.mdx | 4 +- .../record-shapes.mdx | 4 +- .../static-field-updates.mdx | 3 +- .../symbol-fields.mdx | 7 ++- .../types-and-class-definitions/unions.mdx | 7 ++- .../limitations/udp-socket-options.mdx | 3 +- docs/content/limitations/untyped-values.mdx | 18 +++++-- docs/content/limitations/wasi-target.mdx | 10 +++- .../weak-collections-and-memory.mdx | 10 +++- .../limitations/webassembly-modules.mdx | 8 +++- docs/scripts/limitations-partials.mjs | 29 ++++++++--- 92 files changed, 652 insertions(+), 151 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c2bdbea2c..2941691bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,7 @@ The docs site is a standalone pnpm workspace under `docs/`. It uses Next.js App - A labeled bullet (`- **Crypto:** ...`) is `docs/content/limitations/
/