From 11c97074e2f5436acdcfe175372b2677fda4abe5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:58:30 -0700 Subject: [PATCH 01/86] 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 02/86] 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 03/86] 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 04/86] 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 05/86] 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 06/86] 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 07/86] 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 08/86] 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 09/86] 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 10/86] 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 11/86] 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 12/86] 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 13/86] 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 14/86] 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 15/86] 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 ba1724122057e6717466a7e56e53c145f183c0c8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:12 -0700 Subject: [PATCH 16/86] Run String and Number methods on dynamic values and add substr, locale case and toPrecision string-width's `character.codePointAt(0)`, camelcase's `string.toLocaleLowerCase(locale)`, toml's `input.substr(...)` and pretty-bytes' `number.toPrecision(3)` threw "x.y is not a function": the dynamic method dispatch had no answer for these names on strings and numbers. A checked-dynamic receiver calling a String or Number method the runtime dispatch cannot answer (codePointAt, substr, toLocaleLowerCase, toLocaleUpperCase, isWellFormed, toWellFormed, trimLeft, trimRight, toPrecision, toExponential(digits)) now evaluates the receiver and arguments once and dispatches on the runtime kind: a string or number runs the typed lowering, every other kind keeps the runtime dispatch's answer. Names other dyn kinds share (at, concat, slice) and the Annex B HTML names chalk-style builders define (bold, link, ...) keep their existing dispatch. Typed receivers gain substr, the locale case pair, the HTML methods, toPrecision and toExponential(fractionDigits), all previously compile-time refusals. toPrecision/toExponential digits come from exact big-integer arithmetic over the double (ties up on the exact binary value). Locale case mapping follows V8: the first requested locale decides, Turkic dotted/dotless I is implemented, and Lithuanian, Greek uppercasing, objects and malformed tags throw a 'not supported yet' Error. A checker-untyped JS binding holding `string | undefined` boxes into the dyn receiver path for String methods (string-width's baseVisible result). tests/diagnostics/retired-engine-1112-number-methods.ts compiles now and moves back to tests/corpus/1112-number-methods.ts. New runtime functions are reached only from the new lowerings; the existing runtime objects' sections are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 4 + .../lowering/containers/string-and-regexp.ts | 423 +++++++++++++++++- .../src/frontend/lowering/lower-calls.ts | 94 ++++ packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 12 + packages/compiler/src/ir/runtime-features.ts | 2 + .../tests/corpus/1112-number-methods.ts.json | 6 + .../6200-string-annexb-locale-methods.ts.json | 6 + .../6201-number-precision-exponential.ts.json | 6 + .../6202-dyn-string-number-methods.js.json | 6 + .../6203-any-string-number-methods.ts.json | 6 + ...retired-engine-1112-number-methods.ts.json | 6 - packages/runtime/src/scr_lib.c | 131 ++++++ packages/runtime/src/scr_number.c | 160 +++++++ packages/runtime/src/scr_number.h | 11 + packages/runtime/src/scr_regex.c | 216 +++++++++ packages/runtime/src/scr_string_methods.h | 11 + .../1112-number-methods.ts} | 6 +- .../6200-string-annexb-locale-methods.ts | 33 ++ .../6201-number-precision-exponential.ts | 34 ++ .../corpus/6202-dyn-string-number-methods.js | 92 ++++ .../corpus/6203-any-string-number-methods.ts | 15 + tests/diagnostics/dynamic-surface.ts | 9 +- .../__snapshots__/dynamic-surface.ts.txt | 12 +- .../retired-engine-1112-number-methods.ts.txt | 8 - 25 files changed, 1282 insertions(+), 30 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json rename tests/{diagnostics/retired-engine-1112-number-methods.ts => corpus/1112-number-methods.ts} (73%) create mode 100644 tests/corpus/6200-string-annexb-locale-methods.ts create mode 100644 tests/corpus/6201-number-precision-exponential.ts create mode 100644 tests/corpus/6202-dyn-string-number-methods.js create mode 100644 tests/corpus/6203-any-string-number-methods.ts delete mode 100644 tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02bf37192..170ce07db 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -94,6 +94,8 @@ export const LIB_FN_SYMS: Record = { "num.parseInt": "scr_parse_int", "num.parseFloat": "scr_parse_float", "str.localeCompare": "scr_str_locale_compare", + "str.substr": "scr_str_substr", + "str.localeCase": "scr_str_locale_case", "str.replaceLiteral": "scr_str_replace_literal", "str.replaceChars": "scr_str_replace_chars", "num.fromString": "scr_string_to_number", @@ -143,6 +145,8 @@ export const LIB_FN_SYMS: Record = { "num.toExponential": "scr_num_to_exponential", "num.toFixed0": "scr_num_to_fixed0", "num.toFixed": "scr_num_to_fixed", + "num.toPrecision": "scr_num_to_precision", + "num.toExponentialDigits": "scr_num_to_exponential_digits", "num.toStringRadix": "scr_num_to_string_radix", "num.sameValue": "scr_num_same_value", "intl.numFormatEnUs": "scr_intl_num_format_en_us", diff --git a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts index 361441636..27634886a 100644 --- a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts +++ b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts @@ -1,4 +1,11 @@ -import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../../ir/build.js"; +import { + boolLit, + dynUndefinedExpr, + nodeThrowExpr, + numLit, + strLit, + varRef, +} from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { BOOL, @@ -315,6 +322,21 @@ export function lowerStringIndexCall( ) { index = lowerer.coerceInto(indexNode, index, DYN); } + return stringIndexHelperCall(lowerer, call, method, receiver, receiverNode, index, indexNode); +} + +/** The relative-index / code-point helper call over an already lowered + * index (any type positionNumber converts — dyn indices included). */ +function stringIndexHelperCall( + lowerer: Lowerer, + call: ts.CallExpression, + method: "at" | "codePointAt", + receiver: IrExpr, + receiverNode: ts.Node, + index: IrExpr, + indexNode: ts.Expression | undefined, +): IrExpr { + const loc = locOf(call); const valueType = method === "at" ? STRING : F64; const resultType = lowerer.withUndefinedArmOf(valueType); if (!resultType || resultType.kind !== "union") @@ -1057,7 +1079,8 @@ export function lowerStringMethodCall( const replaceMethod = STRING_REPLACE_METHODS.has(access.name.text) ? (access.name.text as "replace" | "replaceAll") : null; - if (!entry && !indexMethod && !replaceMethod) return null; + const extraMethod = EXTRA_STRING_METHODS.has(access.name.text); + if (!entry && !indexMethod && !replaceMethod && !extraMethod) return null; // A validated dyn receiver (`pkg.name.replace(...)` on a JSON.parse // value) arrives pre-extracted through `dynReceiver`; its checker type // is `any`, so the type/symbol gates don't apply — the dyn value's @@ -1085,6 +1108,12 @@ export function lowerStringMethodCall( return null; if (!lowerer.isStdlibMember(access)) return null; } + if (extraMethod && argumentNodes === call.arguments) { + const receiver = dynReceiver + ? dynReceiver() + : lowerMethodReceiver(lowerer, access.expression, STRING, access.name.text); + return lowerExtraStringMethodCall(lowerer, call, access.name.text, receiver, access.expression); + } // The lib declares optional parameters beyond some lowered forms; fence // those arities instead of passing arguments the runtime doesn't take. if ( @@ -1382,3 +1411,393 @@ function lowerLocaleCompareCall( lowerer.badType(call.arguments[0]!, lowerer.typeOf(call.arguments[0]!)); return { kind: "libCall", fn: "str.localeCompare", args: [receiver, arg], type: F64, loc }; } + +/* ── String.prototype / Number.prototype members beyond the core tables ── + * substr, the locale case pair, the Annex B HTML methods, toPrecision and + * toExponential(fractionDigits) on typed receivers, plus the kind dispatch + * for every String/Number method a checked-dynamic receiver can name + * whose runtime dispatch had no answer (`character.codePointAt(0)` in a + * JS package, `n.toPrecision(3)` on a JS-inferred number). Each path + * replaces a compile-time refusal, a runtime 'not supported yet' fence or + * Node's is-not-a-function answer for a real method, so programs that + * never spell these names compile exactly as before. */ + +/** String.prototype members (Annex B included) whose typed-receiver + * lowering lives here. */ +export const EXTRA_STRING_METHODS: ReadonlySet = new Set([ + "substr", + "toLocaleLowerCase", + "toLocaleUpperCase", + "anchor", + "big", + "blink", + "bold", + "fixed", + "fontcolor", + "fontsize", + "italics", + "link", + "small", + "strike", + "sub", + "sup", +]); + +/** String methods a dyn receiver dispatches by runtime kind here: a string + * runs the typed lowering, every other kind takes the runtime dispatch + * (an object's own member, Node's is-not-a-function otherwise). Only names + * no other dyn kind's prototype declares: the shared ones (at, concat, + * slice, ...) already dispatch at run time for arrays, and a kind test in + * front of them would change array code that works today. The Annex B + * HTML names (bold, italics, link, ...) stay out too: chalk-style builders + * call them on functions and objects, whose calls must stay as they are. */ +const DYN_STRING_KIND_METHODS: ReadonlySet = new Set([ + "substr", + "toLocaleLowerCase", + "toLocaleUpperCase", + "codePointAt", + "isWellFormed", + "toWellFormed", + "trimLeft", + "trimRight", +]); + +/** Number methods a dyn receiver dispatches by runtime kind here. */ +const DYN_NUMBER_KIND_METHODS: ReadonlySet = new Set(["toPrecision", "toExponential"]); + +/** Annex B CreateHTML: the tag and the attribute (null for none). */ +const HTML_METHODS: Readonly> = { + anchor: ["a", "name"], + big: ["big", null], + blink: ["blink", null], + bold: ["b", null], + fixed: ["tt", null], + fontcolor: ["font", "color"], + fontsize: ["font", "size"], + italics: ["i", null], + link: ["a", "href"], + small: ["small", null], + strike: ["strike", null], + sub: ["sub", null], + sup: ["sup", null], +}; + +export function isDynPrimitiveKindMethod(name: string): boolean { + return DYN_STRING_KIND_METHODS.has(name) || DYN_NUMBER_KIND_METHODS.has(name); +} + +/** Evaluate `value` once into a hidden local (effect-free reads stay). */ +function hoist(lowerer: Lowerer, value: IrExpr, stmts: IrStmt[], name: string): IrExpr { + if ( + value.kind === "varRef" || + value.kind === "numLit" || + value.kind === "strLit" || + value.kind === "boolLit" || + value.kind === "unitLit" + ) + return value; + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc }); + return varRef(local.id, value.type, value.loc); +} + +function seq(stmts: IrStmt[], result: IrExpr): IrExpr { + return stmts.length === 0 + ? result + : { kind: "seqExpr", stmts, result, type: result.type, loc: result.loc }; +} + +/** ToString of a method argument: the dyn coercion (symbols throw), the + * typed conversions elsewhere. */ +function stringArg(lowerer: Lowerer, value: IrExpr, node: ts.Node): IrExpr { + if (value.type.kind === "dyn") + return { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [value], + type: STRING, + loc: value.loc, + }; + return lowerer.ensureString(value, node); +} + +/** An optional numeric argument: its ToNumber value and whether it was + * supplied (undefined counts as absent, as the spec's checks do). */ +function optionalNumber( + lowerer: Lowerer, + value: IrExpr | undefined, + node: ts.Expression, + stmts: IrStmt[], + subject: string, + loc: SrcLoc, +): { value: IrExpr; has: IrExpr } { + if (!value || value.type.kind === "undefinedT" || value.type.kind === "void") + return { value: numLit(0, loc), has: boolLit(false, loc) }; + if ( + value.type.kind === "f64" || + value.type.kind === "string" || + value.type.kind === "bool" || + value.type.kind === "nullT" + ) + return { + value: positionNumber(lowerer, value, numLit(0, loc), node, subject), + has: boolLit(true, loc), + }; + const boxed = + value.type.kind === "dyn" + ? value + : hoist(lowerer, lowerer.coerceToExpected(value, DYN), stmts, "%numArg"); + const absent: IrExpr = { kind: "dynTest", test: "undefined", value: boxed, type: BOOL, loc }; + return { + value: positionNumber(lowerer, boxed, numLit(0, loc), node, subject), + has: { kind: "unary", op: "!", operand: absent, type: BOOL, loc }, + }; +} + +/** The typed result of a String.prototype member over a STRING receiver + * and already evaluated arguments, or null when the name is not one of + * this module's. */ +function buildStringMethod( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + s: IrExpr, + args: IrExpr[], + receiverNode: ts.Node, + stmts: IrStmt[], +): IrExpr | null { + const loc = locOf(call); + const node = (i: number): ts.Expression => call.arguments[i] ?? call; + if (name === "substr") { + const start = args[0] + ? positionNumber(lowerer, args[0], numLit(0, loc), node(0), "substr start") + : numLit(0, loc); + const length = optionalNumber(lowerer, args[1], node(1), stmts, "substr length", loc); + return { + kind: "libCall", + fn: "str.substr", + args: [s, start, length.value, length.has], + type: STRING, + loc, + }; + } + if (name === "toLocaleLowerCase" || name === "toLocaleUpperCase") { + const locales = args[0] ? lowerer.coerceToExpected(args[0], DYN) : dynUndefinedExpr(loc); + return { + kind: "libCall", + fn: "str.localeCase", + args: [s, locales, boolLit(name === "toLocaleUpperCase", loc)], + type: STRING, + loc, + }; + } + const html = Object.hasOwn(HTML_METHODS, name) ? HTML_METHODS[name] : undefined; + if (html) { + const [tag, attribute] = html; + const subject = hoist(lowerer, s, stmts, "%htmlSubject"); + const pieces: IrExpr[] = [strLit(`<${tag}`, loc)]; + if (attribute !== null) { + const text = args[0] ? stringArg(lowerer, args[0], node(0)) : strLit("undefined", loc); + pieces.push(strLit(` ${attribute}="`, loc)); + pieces.push({ + kind: "libCall", + fn: "str.replaceLiteral", + args: [text, strLit('"', loc), strLit(""", loc), boolLit(true, loc)], + type: STRING, + loc, + }); + pieces.push(strLit('"', loc)); + } + pieces.push(strLit(">", loc), subject, strLit(``, loc)); + return pieces.reduce((left, right) => ({ kind: "strConcat", left, right, type: STRING, loc })); + } + if (name === "codePointAt") + return stringIndexHelperCall( + lowerer, + call, + name, + s, + receiverNode, + args[0] ?? numLit(0, loc), + call.arguments[0], + ); + if (name === "isWellFormed" || name === "toWellFormed") + return { + kind: "strIntrinsic", + method: name, + receiver: s, + args: [], + type: name === "isWellFormed" ? BOOL : STRING, + loc, + }; + if (name === "trimLeft" || name === "trimRight") + return { + kind: "strIntrinsic", + method: name === "trimLeft" ? "trimStart" : "trimEnd", + receiver: s, + args: [], + type: STRING, + loc, + }; + return null; +} + +/** The typed result of toPrecision/toExponential over an F64 receiver. */ +function buildNumberMethod( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + x: IrExpr, + args: IrExpr[], + stmts: IrStmt[], +): IrExpr | null { + const loc = locOf(call); + if (name !== "toPrecision" && name !== "toExponential") return null; + const digits = optionalNumber( + lowerer, + args[0], + call.arguments[0] ?? call, + stmts, + `${name} digits`, + loc, + ); + if (name === "toPrecision") + return { + kind: "libCall", + fn: "num.toPrecision", + args: [x, digits.value, digits.has], + type: STRING, + loc, + }; + const shortest: IrExpr = { + kind: "libCall", + fn: "num.toExponential", + args: [x], + type: STRING, + loc, + }; + if (digits.has.kind === "boolLit" && !digits.has.value) return shortest; + const fixed: IrExpr = { + kind: "libCall", + fn: "num.toExponentialDigits", + args: [x, digits.value], + type: STRING, + loc, + }; + if (digits.has.kind === "boolLit") return fixed; + return { kind: "ternary", cond: digits.has, then: fixed, else_: shortest, type: STRING, loc }; +} + +/** Evaluate the call's arguments, in order, into hidden locals. */ +function evaluatedArgs( + lowerer: Lowerer, + call: ts.CallExpression, + stmts: IrStmt[], + expected: IrType | undefined, +): IrExpr[] { + return call.arguments.map((arg) => + hoist( + lowerer, + expected ? lowerer.lowerExprExpecting(arg, expected) : lowerer.lowerExpr(arg), + stmts, + "%methodArg", + ), + ); +} + +/** A typed STRING receiver calling one of EXTRA_STRING_METHODS: receiver, + * then arguments, then the method's own conversions — the spec order. */ +export function lowerExtraStringMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + receiver: IrExpr, + receiverNode: ts.Node, +): IrExpr | null { + if (call.arguments.some(ts.isSpreadElement)) return null; + const stmts: IrStmt[] = []; + const s = hoist(lowerer, receiver, stmts, "%stringReceiver"); + const args = evaluatedArgs(lowerer, call, stmts, undefined); + const result = buildStringMethod(lowerer, call, name, s, args, receiverNode, stmts); + return result ? seq(stmts, result) : null; +} + +/** A typed F64 receiver calling toPrecision(p) or toExponential(f). */ +export function lowerExtraNumberMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + receiver: IrExpr, +): IrExpr | null { + if (call.arguments.some(ts.isSpreadElement) || call.arguments.length > 1) return null; + if (name !== "toPrecision" && !(name === "toExponential" && call.arguments.length === 1)) + return null; + const stmts: IrStmt[] = []; + const x = hoist(lowerer, receiver, stmts, "%numberReceiver"); + const args = evaluatedArgs(lowerer, call, stmts, undefined); + const result = buildNumberMethod(lowerer, call, name, x, args, stmts); + return result ? seq(stmts, result) : null; +} + +/** `recv.m(args)` on a checked-dynamic receiver for a String/Number method + * the runtime dispatch cannot answer: the receiver and arguments evaluate + * once, a string (or number) receiver runs the typed lowering, and every + * other kind keeps the runtime dispatch's answer. Null for other names. */ +export function lowerDynPrimitiveMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + calleeName: string, + recv: IrExpr, +): IrExpr | null { + const stringMethod = DYN_STRING_KIND_METHODS.has(name); + const numberMethod = DYN_NUMBER_KIND_METHODS.has(name); + if (!stringMethod && !numberMethod) return null; + if (call.arguments.some(ts.isSpreadElement)) return null; + const loc = locOf(call); + const stmts: IrStmt[] = []; + const local = lowerer.declareHiddenLocal("%primitiveReceiver", DYN); + stmts.push({ kind: "varDecl", localId: local.id, init: recv, loc }); + const r = varRef(local.id, DYN, loc); + const args = evaluatedArgs(lowerer, call, stmts, DYN); + let result: IrExpr = { + kind: "dynInvoke", + recv: r, + method: name, + calleeName, + args, + type: DYN, + loc, + }; + const branch = ( + test: "string" | "number", + build: (value: IrExpr, branchStmts: IrStmt[]) => IrExpr | null, + ): void => { + const branchStmts: IrStmt[] = []; + const value: IrExpr = { + kind: "dynCheck", + value: r, + type: test === "string" ? STRING : F64, + loc, + }; + const built = build(value, branchStmts); + if (!built) return; + result = { + kind: "ternary", + cond: { kind: "dynTest", test, value: r, type: BOOL, loc }, + then: { kind: "dynFrom", value: seq(branchStmts, built), type: DYN, loc }, + else_: result, + type: DYN, + loc, + }; + }; + if (numberMethod) + branch("number", (x, branchStmts) => + buildNumberMethod(lowerer, call, name, x, args, branchStmts), + ); + if (stringMethod) + branch("string", (s, branchStmts) => + buildStringMethod(lowerer, call, name, s, args, call.expression, branchStmts), + ); + return seq(stmts, result); +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..ca4dd0edb 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -95,6 +95,9 @@ import { lowerStringMethodCall, lowerStringPaddingCall, lowerStringSplitCall, + isDynPrimitiveKindMethod, + lowerDynPrimitiveMethodCall, + lowerExtraNumberMethodCall, } from "./containers/string-and-regexp.js"; import { createRequireSpecOf } from "./builtins/module-bindings.js"; import { @@ -3274,6 +3277,23 @@ function lowerDynReceiverMethodCall( lowerStringMethodCall(lowerer, call, access, () => recv) ); } + // A checker-untyped JS binding holding `string | undefined` (string- + // width's `const visibleSegment = baseVisible(segment)` after an + // `=== undefined` guard the checker cannot see through): no typed + // lowering claims the union, so a String method boxes it and takes the + // dyn receiver path, whose undefined/null arms throw Node's read errors. + if ( + checkerUntyped && + recv.type.kind === "union" && + isJsSourceFile(call.getSourceFile()) && + isStringMethodName(access.name.text) && + lowerer.unions + .get(recv.type.unionId) + ?.arms.every((arm) => arm.kind === "string" || isUnitType(arm)) === true && + lowerer.dynConvertible(recv.type) + ) { + recv = lowerer.coerceToExpected(recv, DYN); + } if (recv.type.kind !== "dyn") return null; if ( access.name.text === "normalize" && @@ -3393,6 +3413,26 @@ function lowerDynReceiverMethodCall( lowerStringMethodCall(lowerer, call, access, checked) ); } + // String/Number methods the runtime dispatch has no answer for + // (codePointAt, substr, toLocaleLowerCase, toPrecision, ...): a string + // or number receiver runs the typed lowering, every other kind keeps the + // runtime dispatch. + if ( + !ownCallable && + !call.questionDotToken && + !access.questionDotToken && + isDynPrimitiveKindMethod(access.name.text) && + !lowerer.chainBlocked(access, call) + ) { + const primitive = lowerDynPrimitiveMethodCall( + lowerer, + call, + access.name.text, + access.getText(), + recv, + ); + if (primitive) return primitive; + } // The argument is a radix for bigint and an encoding for Buffer. // Preserve its runtime value so receiver dispatch can choose correctly. if (access.name.text === "toString" && call.arguments.length <= 1) { @@ -3968,6 +4008,19 @@ export function lowerDynDispatchMethodCall( }; } +/** Every String.prototype method name a typed or dyn string lowering + * answers. */ +function isStringMethodName(name: string): boolean { + return ( + Object.hasOwn(STR_METHODS, name) || + STRING_INDEX_METHODS.has(name) || + DYN_STRING_ONLY_METHODS.has(name) || + isDynPrimitiveKindMethod(name) || + name === "normalize" || + name === "lastIndexOf" + ); +} + /** STR_METHODS ∪ the regex-form names, MINUS everything Array (or any * other dyn kind's prototype) also declares. */ const DYN_STRING_ONLY_METHODS = new Set([ @@ -4410,6 +4463,21 @@ function lowerPrimitiveProtoCall( isJsSourceFile(call.getSourceFile()) ) { const operand = lowerer.lowerExpr(recv); + if ( + name === "toExponential" && + call.arguments.length === 1 && + (operand.type.kind === "dyn" || + (operand.type.kind === "union" && lowerer.dynConvertible(operand.type))) + ) { + const dispatched = lowerDynPrimitiveMethodCall( + lowerer, + call, + name, + `${recv.getText()}.${name}`, + lowerer.coerceToExpected(operand, DYN), + ); + if (dispatched) return dispatched; + } if ( operand.type.kind === "dyn" || (operand.type.kind === "union" && lowerer.dynConvertible(operand.type)) @@ -4424,6 +4492,32 @@ function lowerPrimitiveProtoCall( loc: locOf(call), }; } + if ( + name === "toPrecision" && + call.arguments.length <= 1 && + !call.arguments.some(ts.isSpreadElement) && + isJsSourceFile(call.getSourceFile()) && + recvKind === "union" + ) { + const operand = lowerer.lowerExpr(recv); + if (operand.type.kind === "union" && lowerer.dynConvertible(operand.type)) { + const dispatched = lowerDynPrimitiveMethodCall( + lowerer, + call, + name, + `${recv.getText()}.${name}`, + lowerer.coerceToExpected(operand, DYN), + ); + if (dispatched) return dispatched; + } + } + if ( + recvKind === "f64" && + (name === "toPrecision" || (name === "toExponential" && call.arguments.length === 1)) + ) { + const typed = lowerExtraNumberMethodCall(lowerer, call, name, lowerer.lowerExpr(recv)); + if (typed) return typed; + } if (recvKind !== "f64" && recvKind !== "bool" && recvKind !== "string") return null; const loc = locOf(call); if (name === "toString" && call.arguments.length === 0) { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1bb9cbd14..b537923a9 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -136,6 +136,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fetch.streamNew", "fetch.streamFrom", "num.toFixed", + "num.toPrecision", + "num.toExponentialDigits", + "str.localeCase", "num.toStringRadix", "insp.jsonDyn", // diagnostics_channel: publish runs subscribers synchronously (a throw diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 08c493ae4..ab4d0ba7f 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -686,6 +686,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** `a.localeCompare(b)`: -1/0/1 in Node's root-locale collation for * Latin-script text (scr_str_locale_compare). Never throws. */ "str.localeCompare": { argTypes: [STRING, STRING], result: F64 }, + /** Annex B substr(start, length) with start ToNumber'd and length's + * presence; never throws. */ + "str.substr": { argTypes: [STRING, F64, F64, BOOL], result: STRING }, + /** toLocaleLowerCase/toLocaleUpperCase (scr_regex.c): the locales value + * as written, upper=true for toLocaleUpperCase. Throws for null and for + * locale forms with no native rules. */ + "str.localeCase": { argTypes: [STRING, DYN, BOOL], result: STRING }, "str.replaceLiteral": { argTypes: [STRING, STRING, STRING, BOOL], result: STRING }, // (subject, characters, NUL-separated replacement texts): the fused // chain of global one-character replacements. @@ -750,6 +757,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "num.toExponential": { argTypes: [F64], result: STRING }, "num.toFixed0": { argTypes: [F64], result: STRING }, "num.toFixed": { argTypes: [F64, F64], result: STRING }, + /** toPrecision(precision) with the argument ToNumber'd and its presence + * (undefined answers ToString); toExponential with a DEFINED + * fractionDigits. Both throw V8's RangeError for out-of-range digits. */ + "num.toPrecision": { argTypes: [F64, F64, BOOL], result: STRING }, + "num.toExponentialDigits": { argTypes: [F64, F64], result: STRING }, "num.toStringRadix": { argTypes: [F64, DYN], result: STRING }, /** Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is `===`. Plain bool diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index d16edfabd..15e9ad999 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -422,6 +422,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run } if (fn.startsWith("dgram.") || fn.startsWith("dns.")) features.dgram = true; if (fn.startsWith("tlsca.")) features.tlsCa = true; + // Locale case mapping runs the default conversion of the regex unit. + if (fn === "str.localeCase") features.regex = true; // scr_cipher.c over the mbedTLS archive; DEP0182 reports through the // process-warning unit, and unknown output encodings render through // the inspect unit. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json new file mode 100644 index 000000000..c95861fe3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/1112-number-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json new file mode 100644 index 000000000..17a5c1e30 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6200-string-annexb-locale-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json new file mode 100644 index 000000000..b80384ada --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6201-number-precision-exponential.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json new file mode 100644 index 000000000..dbf5146ed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6202-dyn-string-number-methods.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json new file mode 100644 index 000000000..42b17b7bb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6203-any-string-number-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json deleted file mode 100644 index 27b24a99a..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-1112-number-methods.ts" - ], - "diags": [] -} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index fd51e9c78..45d9b7e2b 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10730,6 +10730,137 @@ ScrStr *scr_num_to_fixed(double x, double fraction_digits) { return scr_str_new(out, len); } +/* String.prototype.substr (Annex B.2.2.1) over substring's UTF-16 + * clamp: -Infinity and other starts below -size clamp to 0, an Infinity + * length to the end. Comparisons only (no floating-point literal other + * than zero, which would join the constant pool every binary links). + * Nothing on an existing path calls this. */ +ScrStr *scr_str_substr(ScrStr *s, double start, double length, bool has_length) { + double size = scr_str_utf16_len(s); + double first = isnan(start) ? 0 : trunc(start); + if (first < 0) { + first = size + first; + if (first < 0) first = 0; + } else if (first > size) { + first = size; + } + double count = has_length ? (isnan(length) ? 0 : trunc(length)) : size; + if (count < 0) count = 0; + double end = first + count; + if (end > size) end = size; + return scr_str_substring(s, first, end); +} + +/* The exponent suffix of toPrecision/toExponential: "e+5", "e-7". */ +static size_t scr_num_exp_suffix(char *o, int e) { + size_t n = 0; + o[n++] = 'e'; + o[n++] = e < 0 ? '-' : '+'; + int a = e < 0 ? -e : e; + char rev[8]; + int k = 0; + do { + rev[k++] = (char)('0' + a % 10); + a /= 10; + } while (a != 0); + while (k > 0) o[n++] = rev[--k]; + return n; +} + +/* Number.prototype.toPrecision(precision) (ECMA-262 §21.1.3.5): undefined + * precision is ToString(x); ToIntegerOrInfinity runs (in the caller's + * coercion) before the non-finite arm, which precedes the 1..100 range + * check. Digits are the exact-value, ties-up n of + * scr_f64_precision_digits; exponential form when e < -6 or e >= p. + * Nothing on an existing path calls this. */ +ScrStr *scr_num_to_precision(double x, double precision, bool has_precision) { + if (!has_precision || !isfinite(x)) return scr_f64_to_scrstr(x); + double p = isnan(precision) ? 0 : trunc(precision); + if (!(p >= 1 && p <= 100)) { + static const char msg[] = "toPrecision() argument must be between 1 and 100"; + scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); + return NULL; + } + int digits_count = (int)p; + char out[160]; + size_t o = 0; + double a = x; + if (x < 0) { + out[o++] = '-'; + a = -x; + } + char d[104]; + int e = 0; + if (a == 0) { + memset(d, '0', (size_t)digits_count); + d[digits_count] = '\0'; + } else { + scr_f64_precision_digits(a, digits_count, d, &e); + } + if (e < -6 || e >= digits_count) { + out[o++] = d[0]; + if (digits_count != 1) { + out[o++] = '.'; + memcpy(out + o, d + 1, (size_t)(digits_count - 1)); + o += (size_t)(digits_count - 1); + } + o += scr_num_exp_suffix(out + o, e); + } else if (e >= 0) { + memcpy(out + o, d, (size_t)(e + 1)); + o += (size_t)(e + 1); + if (e + 1 < digits_count) { + out[o++] = '.'; + memcpy(out + o, d + e + 1, (size_t)(digits_count - e - 1)); + o += (size_t)(digits_count - e - 1); + } + } else { + out[o++] = '0'; + out[o++] = '.'; + for (int i = 0; i < -(e + 1); i++) out[o++] = '0'; + memcpy(out + o, d, (size_t)digits_count); + o += (size_t)digits_count; + } + return scr_str_new(out, o); +} + +/* Number.prototype.toExponential(fractionDigits) with a DEFINED argument + * (the undefined form is scr_num_to_exponential's shortest mantissa): + * f + 1 exact-value, ties-up digits, "d.ddde±X". Nothing on an existing + * path calls this. */ +ScrStr *scr_num_to_exponential_digits(double x, double fraction_digits) { + if (!isfinite(x)) return scr_f64_to_scrstr(x); + double f = isnan(fraction_digits) ? 0 : trunc(fraction_digits); + if (!(f >= 0 && f <= 100)) { + static const char msg[] = "toExponential() argument must be between 0 and 100"; + scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); + return NULL; + } + int count = (int)f + 1; + char out[160]; + size_t o = 0; + double a = x; + if (x < 0) { + out[o++] = '-'; + a = -x; + } + char d[104]; + int e = 0; + if (a == 0) { + memset(d, '0', (size_t)count); + d[count] = '\0'; + } else { + scr_f64_precision_digits(a, count, d, &e); + } + out[o++] = d[0]; + if (count != 1) { + out[o++] = '.'; + memcpy(out + o, d + 1, (size_t)(count - 1)); + o += (size_t)(count - 1); + } + o += scr_num_exp_suffix(out + o, e); + return scr_str_new(out, o); +} + /* Increment a decimal digit string in place. Returns true on overflow — * the value becomes 1 followed by len zeros (the caller folds the zeros * into its scale); an EMPTY string increments to "1" the same way (the diff --git a/packages/runtime/src/scr_number.c b/packages/runtime/src/scr_number.c index 1ccc414b7..dc8743e14 100644 --- a/packages/runtime/src/scr_number.c +++ b/packages/runtime/src/scr_number.c @@ -654,3 +654,163 @@ size_t scr_f64_to_fixed(double x, int f, char *buf) { *o = '\0'; return (size_t)(o - buf); } + +/* ── toPrecision / toExponential(fractionDigits) digit engine ────────── + * The spec's n and e for p significant digits: the integer n with + * 10^(p-1) <= n < 10^p for which n × 10^(e-p+1) - x is closest to zero, + * ties picking the LARGER n (half-up on the exact binary value). Exact + * big-integer arithmetic over the double's mantissa: the value scaled by + * 10^(p-1-e) is mantissa × 2^(binary_exp + f) × 5^f, and + * round_half_up(v) = floor((floor(2v) + 1) / 2) composes from floor + * divisions alone. Values reach ~1100 bits (5e-324 at 100 digits and + * 1.8e308), so the limbs are wider than toFixed's. Nothing on an existing + * path calls this. */ +#define SCR_PREC_LIMBS 40 +typedef struct { + uint32_t limb[SCR_PREC_LIMBS]; /* little-endian */ + int len; +} ScrPrecInt; + +static void scr_prec_normalize(ScrPrecInt *v) { + while (v->len > 1 && v->limb[v->len - 1] == 0) v->len--; +} + +static void scr_prec_mul_small(ScrPrecInt *v, uint32_t m) { + uint64_t carry = 0; + for (int i = 0; i < v->len; i++) { + uint64_t p = (uint64_t)v->limb[i] * m + carry; + v->limb[i] = (uint32_t)p; + carry = p >> 32; + } + if (carry != 0) v->limb[v->len++] = (uint32_t)carry; +} + +static uint32_t scr_prec_div_small(ScrPrecInt *v, uint32_t d) { + uint64_t rem = 0; + for (int i = v->len - 1; i >= 0; i--) { + uint64_t cur = (rem << 32) | v->limb[i]; + v->limb[i] = (uint32_t)(cur / d); + rem = cur % d; + } + scr_prec_normalize(v); + return (uint32_t)rem; +} + +static void scr_prec_shl(ScrPrecInt *v, int bits) { + uint32_t out[SCR_PREC_LIMBS] = {0}; + int words = bits / 32; + int rem = bits % 32; + for (int i = 0; i < v->len; i++) { + int at = i + words; + out[at] |= v->limb[i] << rem; + if (rem != 0 && at + 1 < SCR_PREC_LIMBS) out[at + 1] |= v->limb[i] >> (32 - rem); + } + int n = v->len + words + 1; + if (n > SCR_PREC_LIMBS) n = SCR_PREC_LIMBS; + memcpy(v->limb, out, sizeof out); + v->len = n; + scr_prec_normalize(v); +} + +static void scr_prec_shr(ScrPrecInt *v, int bits) { + int words = bits / 32; + int rem = bits % 32; + if (words >= v->len) { + v->limb[0] = 0; + v->len = 1; + return; + } + int n = v->len - words; + for (int i = 0; i < n; i++) { + uint32_t lo = v->limb[i + words] >> rem; + uint32_t hi = rem != 0 && i + words + 1 < v->len ? v->limb[i + words + 1] << (32 - rem) : 0; + v->limb[i] = lo | hi; + } + v->len = n; + scr_prec_normalize(v); +} + +/* round_half_up(a × 10^f) for positive finite a, as a decimal digit + * string written right-aligned ending at `end`; returns the first digit. */ +static char *scr_prec_scaled_digits(double a, int f, char *end) { + uint64_t bits; + memcpy(&bits, &a, sizeof bits); + uint64_t mantissa = bits & ((UINT64_C(1) << 52) - 1); + int ieee_exp = (int)((bits >> 52) & 0x7ffu); + int binary_exp; + if (ieee_exp == 0) { + binary_exp = -1074; + } else { + mantissa |= UINT64_C(1) << 52; + binary_exp = ieee_exp - 1023 - 52; + } + ScrPrecInt v = {{(uint32_t)mantissa, (uint32_t)(mantissa >> 32)}, 2}; + scr_prec_normalize(&v); + for (int i = 0; i < f; i++) scr_prec_mul_small(&v, 5); + int twos = binary_exp + f + 1; /* the +1 doubles: floor(2v) */ + if (twos > 0) scr_prec_shl(&v, twos); + for (int i = 0; i < -f; i++) scr_prec_div_small(&v, 5); + if (twos < 0) scr_prec_shr(&v, -twos); + /* (floor(2v) + 1) / 2 */ + uint64_t carry = 1; + for (int i = 0; i < v.len && carry != 0; i++) { + uint64_t s = (uint64_t)v.limb[i] + carry; + v.limb[i] = (uint32_t)s; + carry = s >> 32; + } + if (carry != 0) v.limb[v.len++] = (uint32_t)carry; + scr_prec_shr(&v, 1); + char *p = end; + for (;;) { + uint32_t chunk = scr_prec_div_small(&v, 1000000000u); + bool last = v.len == 1 && v.limb[0] == 0; + for (int i = 0; i < 9 && (chunk != 0 || !last || p == end); i++) { + *--p = (char)('0' + chunk % 10); + chunk /= 10; + } + if (last) break; + } + return p; +} + +int scr_f64_precision_digits(double a, int p, char *digits, int *e_out) { + char shortest[18]; + int n; + scr_f64_digits(a, shortest, &n); + int e = n - 1; + char buf[400]; + char *end = buf + sizeof buf; + for (int attempt = 0; attempt < 3; attempt++) { + char *first = scr_prec_scaled_digits(a, p - 1 - e, end); + int len = (int)(end - first); + if (len == p) { + memcpy(digits, first, (size_t)p); + digits[p] = '\0'; + *e_out = e; + return p; + } + if (len > p) { + /* Rounding carried into a new digit (or the estimate was low). */ + if (len == p + 1 && first[0] == '1') { + bool zeros = true; + for (int i = 1; i < len; i++) zeros = zeros && first[i] == '0'; + if (zeros) { + digits[0] = '1'; + memset(digits + 1, '0', (size_t)(p - 1)); + digits[p] = '\0'; + *e_out = e + 1; + return p; + } + } + e++; + } else { + e--; + } + } + /* Unreachable for finite positive doubles: the estimate is off by at + * most one decade. */ + memset(digits, '0', (size_t)p); + digits[p] = '\0'; + *e_out = e; + return p; +} diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index 84db27075..38a362ac9 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -37,6 +37,12 @@ int scr_decimal_scan(const char *p, size_t n, double *out); * the length. */ size_t scr_f64_to_fixed(double x, int f, char *buf); +/* toPrecision / toExponential(fractionDigits) digits for POSITIVE finite + * a and 1 <= p <= 101: the spec's p-digit n (ties to the larger n on the + * exact binary value) into digits (NUL-terminated, >= p + 1 bytes) and its + * decimal exponent e (value ~ d.ddd x 10^e) into *e_out. Returns p. */ +int scr_f64_precision_digits(double a, int p, char *digits, int *e_out); + /* ToString for template literals / string coercion. Returns +1. */ ScrStr *scr_f64_to_scrstr(double x); ScrStr *scr_bool_to_scrstr(bool b); /* interned "true"/"false" */ @@ -73,6 +79,11 @@ bool scr_num_is_safe_integer(double x); ScrStr *scr_num_to_exponential(double x); ScrStr *scr_num_to_fixed0(double x); ScrStr *scr_num_to_fixed(double x, double fraction_digits); +/* Number.prototype.toPrecision(precision) and toExponential(fractionDigits) + * with the argument already coerced (has_precision false = undefined). + * Invalid digit counts throw V8's RangeError (NULL). Result +1. */ +ScrStr *scr_num_to_precision(double x, double precision, bool has_precision); +ScrStr *scr_num_to_exponential_digits(double x, double fraction_digits); /* Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is ==. Never throws. */ diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index b24261a4d..24a11d4b6 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -1742,6 +1742,222 @@ static ScrStr *scr_str_case_conv(const ScrStr *s, int to_lower) { ScrStr *scr_str_to_lower(const ScrStr *s) { return scr_str_case_conv(s, 1); } ScrStr *scr_str_to_upper(const ScrStr *s) { return scr_str_case_conv(s, 0); } +/* ── String.prototype.toLocaleLowerCase / toLocaleUpperCase ──────────── + * V8 decides the case mapping from the FIRST requested locale only + * (probed: ["en", 5] maps like "en", [5] throws, a non-object primitive + * requests nothing). ICU tailors case mapping for Turkic (tr, az: dotted + * and dotless I), Lithuanian (dot above retention) and Greek uppercasing + * (accent removal); every other language uses the default conversion. + * The Turkic rules are SpecialCasing's: lowercase I → ı unless a + * U+0307 follows (then I + U+0307 → i), İ → i; uppercase i → İ. Tags are + * checked against the unicode_locale_id structure; anything this unit + * cannot decide exactly (malformed tags, whose V8 RangeError texts vary, + * objects, lt, el uppercasing, I before other combining marks) throws a + * 'not supported yet' Error rather than guess. Nothing on an existing + * path calls this. */ + +static bool scr_tag_alpha(const char *p, size_t n) { + for (size_t i = 0; i < n; i++) + if (!((p[i] >= 'a' && p[i] <= 'z') || (p[i] >= 'A' && p[i] <= 'Z'))) return false; + return true; +} + +static bool scr_tag_digit(const char *p, size_t n) { + for (size_t i = 0; i < n; i++) + if (!(p[i] >= '0' && p[i] <= '9')) return false; + return true; +} + +static bool scr_tag_alnum(const char *p, size_t n) { + for (size_t i = 0; i < n; i++) + if (!scr_tag_alpha(p + i, 1) && !scr_tag_digit(p + i, 1)) return false; + return true; +} + +static bool scr_tag_same(const char *a, size_t an, const char *b, size_t bn) { + if (an != bn) return false; + for (size_t i = 0; i < an; i++) { + char x = a[i] >= 'A' && a[i] <= 'Z' ? (char)(a[i] + 32) : a[i]; + char y = b[i] >= 'A' && b[i] <= 'Z' ? (char)(b[i] + 32) : b[i]; + if (x != y) return false; + } + return true; +} + +/* 0 default, 1 Turkic, 2 Lithuanian, 3 Greek; -1 when the tag is outside + * the structural grammar handled here. */ +static int scr_locale_case_rules(const char *tag, size_t len) { + const char *subtags[64]; + size_t lens[64]; + size_t count = 0; + size_t start = 0; + for (size_t i = 0; i <= len; i++) { + if (i < len && tag[i] != '-') continue; + if (i == start || count == 64) return -1; + subtags[count] = tag + start; + lens[count++] = i - start; + start = i + 1; + } + size_t k = 0; + size_t lang = lens[0]; + if (!scr_tag_alpha(subtags[0], lang) || !((lang >= 2 && lang <= 3) || (lang >= 5 && lang <= 8))) + return -1; + k = 1; + if (k < count && lens[k] == 4 && scr_tag_alpha(subtags[k], 4)) k++; + if (k < count && ((lens[k] == 2 && scr_tag_alpha(subtags[k], 2)) || + (lens[k] == 3 && scr_tag_digit(subtags[k], 3)))) + k++; + size_t variants = k; + while (k < count && scr_tag_alnum(subtags[k], lens[k]) && + ((lens[k] >= 5 && lens[k] <= 8) || (lens[k] == 4 && scr_tag_digit(subtags[k], 1)))) { + for (size_t j = variants; j < k; j++) + if (scr_tag_same(subtags[j], lens[j], subtags[k], lens[k])) return -1; + k++; + } + size_t singletons = k; + while (k < count) { + if (lens[k] != 1 || !scr_tag_alnum(subtags[k], 1)) return -1; + for (size_t j = singletons; j < k; j++) + if (lens[j] == 1 && scr_tag_same(subtags[j], 1, subtags[k], 1)) return -1; + bool private_use = subtags[k][0] == 'x' || subtags[k][0] == 'X'; + size_t min = private_use ? 1 : 2; + size_t first = ++k; + while (k < count && lens[k] >= min && lens[k] <= 8 && scr_tag_alnum(subtags[k], lens[k]) && + (private_use || lens[k] > 1)) + k++; + if (k == first) return -1; + if (private_use && k != count) return -1; + } + if (lang > 3) return 0; + char l[3] = {0, 0, 0}; + for (size_t i = 0; i < lang; i++) + l[i] = subtags[0][i] >= 'A' && subtags[0][i] <= 'Z' ? (char)(subtags[0][i] + 32) : subtags[0][i]; + bool two = lang == 2; + if ((two && l[0] == 't' && l[1] == 'r') || (two && l[0] == 'a' && l[1] == 'z') || + (!two && l[0] == 't' && l[1] == 'u' && l[2] == 'r') || + (!two && l[0] == 'a' && l[1] == 'z' && l[2] == 'e')) + return 1; + if ((two && l[0] == 'l' && l[1] == 't') || (!two && l[0] == 'l' && l[1] == 'i' && l[2] == 't')) + return 2; + if ((two && l[0] == 'e' && l[1] == 'l') || (!two && l[0] == 'e' && l[1] == 'l' && l[2] == 'l') || + (!two && l[0] == 'g' && l[1] == 'r' && l[2] == 'e')) + return 3; + return 0; +} + +static ScrStr *scr_locale_case_unsupported(bool upper) { + static const char lower_msg[] = + "String.prototype.toLocaleLowerCase with this locale argument is not supported yet"; + static const char upper_msg[] = + "String.prototype.toLocaleUpperCase with this locale argument is not supported yet"; + /* The unit's existing "SC2020" literal: a separate constant array would + * absorb it (LLVM merges identical constants) and move it out of the + * string section every regex-linking binary keeps. */ + if (upper) scr_throw_error_msg_code(SCR_ERR_ERROR, upper_msg, sizeof upper_msg - 1, "SC2020"); + else scr_throw_error_msg_code(SCR_ERR_ERROR, lower_msg, sizeof lower_msg - 1, "SC2020"); + return NULL; +} + +static ScrStr *scr_turkic_case(const ScrStr *s, bool upper) { + ScrJsonBuf b; + scr_jb_init(&b); + for (size_t i = 0; i < s->len; i++) { + unsigned char c = (unsigned char)s->data[i]; + if (upper && c == 'i') { + scr_jb_putc(&b, (char)0xC4); /* İ */ + scr_jb_putc(&b, (char)0xB0); + continue; + } + if (!upper && c == 'I') { + unsigned char n0 = i + 1 < s->len ? (unsigned char)s->data[i + 1] : 0; + unsigned char n1 = i + 2 < s->len ? (unsigned char)s->data[i + 2] : 0; + if (n0 == 0xCC && n1 == 0x87) { /* I + U+0307 → i */ + scr_jb_putc(&b, 'i'); + i += 2; + continue; + } + /* Another combining mark (U+0300..U+036F) may precede a U+0307. */ + if ((n0 == 0xCC && n1 >= 0x80) || (n0 == 0xCD && n1 >= 0x80 && n1 <= 0xAF)) { + scr_str_release(scr_jb_finish(&b)); + return scr_locale_case_unsupported(upper); + } + scr_jb_putc(&b, (char)0xC4); /* ı */ + scr_jb_putc(&b, (char)0xB1); + continue; + } + if (!upper && c == 0xC4 && i + 1 < s->len && (unsigned char)s->data[i + 1] == 0xB0) { + scr_jb_putc(&b, 'i'); /* İ → i */ + i++; + continue; + } + scr_jb_putc(&b, (char)c); + } + ScrStr *mapped = scr_jb_finish(&b); + ScrStr *result = scr_str_case_conv(mapped, upper ? 0 : 1); + scr_str_release(mapped); + return result; +} + +ScrStr *scr_str_locale_case(const ScrStr *s, ScrDyn *locales, bool upper) { + ScrDyn *owned = NULL; + if (locales->kind == SCR_DYN_TYPED_REF) { + owned = scr_dyn_typed_ref_materialize(locales); + if (!owned) return NULL; + locales = owned; + } + const ScrDyn *tag = NULL; + int rules = 0; + switch (locales->kind) { + case SCR_DYN_NULL: { + static const char msg[] = "Cannot convert undefined or null to object"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + return NULL; + } + case SCR_DYN_UNDEF: + case SCR_DYN_NUM: + case SCR_DYN_BOOL: + case SCR_DYN_SYMBOL: + case SCR_DYN_BIGINT: + break; + case SCR_DYN_STR: + tag = locales; + break; + case SCR_DYN_ARR: + if (locales->v.arr.len == 0) break; + if (locales->v.arr.presence && !locales->v.arr.presence[0]) { + static const char msg[] = "Language ID should be string or object."; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + scr_dyn_release(owned); + return NULL; + } + tag = locales->v.arr.items[0]; + if (tag->kind != SCR_DYN_STR) { + bool primitive = tag->kind == SCR_DYN_UNDEF || tag->kind == SCR_DYN_NULL || + tag->kind == SCR_DYN_NUM || tag->kind == SCR_DYN_BOOL || + tag->kind == SCR_DYN_BIGINT || tag->kind == SCR_DYN_SYMBOL; + scr_dyn_release(owned); + if (!primitive) return scr_locale_case_unsupported(upper); + static const char msg[] = "Language ID should be string or object."; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + return NULL; + } + break; + default: + scr_dyn_release(owned); + return scr_locale_case_unsupported(upper); + } + if (tag) { + rules = scr_locale_case_rules(tag->v.str->data, tag->v.str->len); + if (rules < 0 || rules == 2 || (rules == 3 && upper)) { + scr_dyn_release(owned); + return scr_locale_case_unsupported(upper); + } + } + scr_dyn_release(owned); + if (rules == 1) return scr_turkic_case(s, upper); + return scr_str_case_conv(s, upper ? 0 : 1); +} + static void *scr_normalize_realloc(void *opaque, void *ptr, size_t size) { (void)opaque; if (!size) { free(ptr); return NULL; } diff --git a/packages/runtime/src/scr_string_methods.h b/packages/runtime/src/scr_string_methods.h index 62354142e..6ed2e09df 100644 --- a/packages/runtime/src/scr_string_methods.h +++ b/packages/runtime/src/scr_string_methods.h @@ -73,6 +73,10 @@ bool scr_str_ends_with_from(ScrStr *s, ScrStr *needle, double end_position); ScrStr *scr_str_slice(ScrStr *s, double start, double end); /* substring: clamp-and-swap (negatives clamp to 0, start > end swaps). */ ScrStr *scr_str_substring(ScrStr *s, double start, double end); +/* Annex B substr(start, length): start already ToNumber'd (NaN → 0), a + * negative start counts from the end, length absent (has_length false) + * runs to the end. Returns +1. */ +ScrStr *scr_str_substr(ScrStr *s, double start, double length, bool has_length); /* Scalar-only local slices retain their original owner without copying. * Bounds and split-surrogate replacements are snapshotted at construction. */ @@ -111,6 +115,13 @@ ScrStr *scr_str_trim(ScrStr *s); ScrStr *scr_str_to_lower(const ScrStr *s); ScrStr *scr_str_to_upper(const ScrStr *s); ScrStr *scr_str_normalize(const ScrStr *s, const ScrStr *form); +/* toLocaleLowerCase/toLocaleUpperCase(locales) (scr_regex.c): undefined, + * empty lists, and primitives other than strings use the default case + * conversion; the first requested tag selects the Turkic (tr, az) rules or + * the default ones. Lithuanian, Greek uppercasing and tags outside the + * structural grammar throw a 'not supported yet' Error; null throws Node's + * TypeError. Borrows both; +1 or NULL with an exception pending. */ +ScrStr *scr_str_locale_case(const ScrStr *s, struct ScrDyn *locales, bool upper); /* NFC over a code point buffer (scr_regex.c, beside normalize): a fresh * malloc'd buffer the caller frees, its length in *out_len. Borrows input. */ uint32_t *scr_nfc_code_points(const uint32_t *input, size_t length, size_t *out_len); diff --git a/tests/diagnostics/retired-engine-1112-number-methods.ts b/tests/corpus/1112-number-methods.ts similarity index 73% rename from tests/diagnostics/retired-engine-1112-number-methods.ts rename to tests/corpus/1112-number-methods.ts index c96f9df00..510850dca 100644 --- a/tests/diagnostics/retired-engine-1112-number-methods.ts +++ b/tests/corpus/1112-number-methods.ts @@ -1,7 +1,5 @@ -// Explicit toFixed is static; toPrecision/toString(radix) execute in the -// island. The receiver marshals by value there and the engine auto-boxes -// it, so `this` binds the number exactly as in JS. All stay byte-exact -// differential. +// Number.prototype.toFixed, toPrecision and toString(radix) on typed +// numbers all compile statically and stay byte-exact differential. const n = 1234.5678; console.log(n.toFixed(0), n.toFixed(2), n.toFixed(6)); console.log((0.1 + 0.2).toFixed(17), (-3.7).toFixed(1), (0).toFixed(2)); diff --git a/tests/corpus/6200-string-annexb-locale-methods.ts b/tests/corpus/6200-string-annexb-locale-methods.ts new file mode 100644 index 000000000..8c0c2f66b --- /dev/null +++ b/tests/corpus/6200-string-annexb-locale-methods.ts @@ -0,0 +1,33 @@ +// String.prototype.substr, the locale case pair and the Annex B HTML +// methods on typed strings. substr counts a negative start from the end +// and clamps its length; toLocaleLowerCase/toLocaleUpperCase use the +// default case conversion except for the Turkic languages, decided by the +// first requested locale only; the HTML methods escape '"' in attribute +// values. +const s = "Hello, World"; +console.log(s.substr(0), s.substr(7), s.substr(-5), s.substr(-50, 3), s.substr(3, 4)); +console.log(JSON.stringify([s.substr(20), s.substr(2, -1), s.substr(NaN, 2), s.substr(1, Infinity)])); +console.log(JSON.stringify([s.substr(-Infinity, 2), s.substr(1.9, 2.9), "😀ab".substr(1, 2)])); +function tail(from: number, length?: number): string { + return s.substr(from, length); +} +console.log(tail(2), tail(2, 3), tail(-3, undefined)); + +function lower(text: string, locale?: string | string[]): string { + return text.toLocaleLowerCase(locale); +} +function upper(text: string, locale?: string | string[]): string { + return text.toLocaleUpperCase(locale); +} +const sample = "IİiıÍ ΣΑΣ Straße"; +for (const locale of [undefined, "en", "en-US", "de-DE", "TR", "tr-TR", "az", "tur", "zh-Hant-TW", "und", "en-u-co-phonebk", "en-x-tr"]) { + console.log(String(locale), lower(sample, locale), upper(sample, locale)); +} +console.log(lower(sample, ["tr", "en"]), lower(sample, ["en", "tr"]), lower(sample, [])); +console.log(upper("istanbul", ["az"]), lower("DİYARBAKIR"), lower("İx", "tr").length); +console.log("Dž".toLocaleUpperCase(), "ß".toLocaleUpperCase("de"), "ffi".toLocaleUpperCase("en")); + + +console.log(s.anchor('a"b'), s.big(), s.blink(), s.bold(), s.fixed()); +console.log(s.fontcolor("red"), s.fontsize(7), s.italics(), s.link("https://x/?q=\"1\"")); +console.log(s.small(), s.strike(), s.sub(), s.sup(), "".anchor("")); diff --git a/tests/corpus/6201-number-precision-exponential.ts b/tests/corpus/6201-number-precision-exponential.ts new file mode 100644 index 000000000..7c3d4eff8 --- /dev/null +++ b/tests/corpus/6201-number-precision-exponential.ts @@ -0,0 +1,34 @@ +// Number.prototype.toPrecision and toExponential(fractionDigits): the +// exact binary value rounds half up at the requested digit, exponential +// form takes over when the exponent is below -6 or reaches the precision, +// and the non-finite answers come before the digit-range RangeError. +const values = [0, -0, 1, -1, 2.5, -2.5, 0.5, 1.005, 1.45, 123.456, 0.000001234, 0.0000001234, 1e21, 1e-7, 5e-324, 1.7976931348623157e308, 123456789012345680000, 0.1, 99.99, 9.995, 1e23, -1234.5678]; +for (const v of values) { + const p = [1, 2, 3, 5, 10, 21].map((d) => v.toPrecision(d)); + const e = [0, 1, 2, 6, 20].map((d) => v.toExponential(d)); + console.log(String(v), p.join(" "), e.join(" ")); +} +console.log((123.456).toPrecision(), (123.456).toPrecision(undefined), (1 / 3).toPrecision(100)); +console.log((1 / 3).toExponential(100), (2 ** 70).toPrecision(25), (2 ** -30).toExponential(30)); +console.log(NaN.toPrecision(200), Infinity.toPrecision(0), (-Infinity).toExponential(-1), NaN.toExponential(101)); +console.log((15).toPrecision(1.9), (15).toExponential(1.9), (0.00001).toPrecision(1), (1e-6).toPrecision(2)); +for (const bad of [0, 101, -1, Infinity]) { + try { + console.log((1).toPrecision(bad)); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +for (const bad of [-1, 101, Infinity]) { + try { + console.log((1).toExponential(bad)); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +let n = 0; +for (let i = 1; i < 2000; i++) { + const x = i / 7 + i * 1e-9; + if (x.toPrecision(7) !== Number(x.toPrecision(7)).toPrecision(7)) n++; +} +console.log("stable", n); diff --git a/tests/corpus/6202-dyn-string-number-methods.js b/tests/corpus/6202-dyn-string-number-methods.js new file mode 100644 index 000000000..3f50a4636 --- /dev/null +++ b/tests/corpus/6202-dyn-string-number-methods.js @@ -0,0 +1,92 @@ +// String and Number methods on JS-inferred receivers: string receivers run +// the real method (string-width's codePointAt, camelcase's +// toLocaleLowerCase, toml's substr, pretty-bytes' toPrecision); other +// receivers keep JavaScript's answer for the name, an object's own method +// or Node's TypeError; arrays keep the runtime dispatch of shared names. +function width(text) { + let total = 0; + for (const character of text) { + const code = character.codePointAt(0); + total += code >= 0x1100 ? 2 : 1; + } + return total; +} +console.log(width("abc"), width("古"), width("👍x")); + +function describe(input) { + return [ + input.codePointAt(0), + input.codePointAt(-1), + input.codePointAt("1"), + input.substr(1), + input.substr(-2, 1), + input.toLocaleLowerCase(), + input.toLocaleUpperCase("en-US"), + input.toLocaleLowerCase("tr"), + input.trimLeft(), + input.trimRight(), + input.isWellFormed(), + input.toWellFormed().length, + ]; +} +console.log(JSON.stringify(describe(" Iİ😀 "))); +console.log(JSON.stringify(describe("\ud800Ab"))); +const parsed = JSON.parse('[" I\\u0130\\ud83d\\ude00 ", 1337, 0.000123456, [1, 2, 3]]'); +console.log(JSON.stringify(describe(parsed[0]))); + +function number(value, digits) { + return [value.toPrecision(digits), value.toExponential(digits), value.toPrecision()]; +} +console.log(JSON.stringify(number(1337, 3)), JSON.stringify(number(0.000123456, 2))); +console.log(JSON.stringify(number(-1.5, "4")), JSON.stringify(number(2.5, undefined))); +console.log(JSON.stringify(number(parsed[1], 3)), JSON.stringify(number(parsed[2], parsed[1] - 1335))); + +function prettyBytes(n) { + const units = ["B", "kB", "MB", "GB"]; + const exponent = Math.min(Math.floor(Math.log10(n) / 3), units.length - 1); + return Number((n / 1000 ** exponent).toPrecision(3)) + " " + units[exponent]; +} +console.log(prettyBytes(1337), prettyBytes(100), prettyBytes(12345678)); + +const own = { codePointAt(i) { return "own " + i; }, substr() { return "own substr"; }, toPrecision(d) { return "own " + d; } }; +console.log(describeOwn(own)); +function describeOwn(value) { + return [value.codePointAt(3), value.substr(), value.toPrecision(2)].join("|"); +} +function arrays(list) { + return JSON.stringify([list.at(-1), list.concat([4], 5)]); +} +console.log(arrays([1, 2, 3]), arrays(parsed[3])); + +function attempt(f) { + try { + return String(f()); + } catch (e) { + return e.name + ": " + e.message; + } +} +function fail(value) { + return [ + attempt(() => value.codePointAt(0)), + attempt(() => value.substr(0)), + attempt(() => value.toPrecision(2)), + attempt(() => value.toLocaleLowerCase()), + ].join(" / "); +} +for (const value of [42, "str", undefined, null, {}, parsed[3]]) console.log(fail(value)); +for (const value of JSON.parse('[42, "str", null, {}, true]')) console.log(fail(value)); +console.log(attempt(() => number(1, 200))); +console.log(attempt(() => "x".toLocaleUpperCase(null))); + +// A helper whose parameter the checker leaves untyped answers `string | +// undefined` natively; the guarded receiver still runs String methods. +function afterX(segment) { + const index = segment.indexOf("x"); + return index === -1 ? undefined : segment.slice(index + 1); +} +function tailCode(text) { + const rest = afterX(text); + if (rest === undefined) return -1; + return [rest.codePointAt(0), rest.substr(1), rest.toUpperCase(), rest.slice(1)].join(","); +} +console.log(tailCode("axé😀"), tailCode("abc")); diff --git a/tests/corpus/6203-any-string-number-methods.ts b/tests/corpus/6203-any-string-number-methods.ts new file mode 100644 index 000000000..202365e5e --- /dev/null +++ b/tests/corpus/6203-any-string-number-methods.ts @@ -0,0 +1,15 @@ +// String and Number methods through 'any'-typed values: string and number +// receivers run the real methods, other values keep JavaScript's answer. +const values: any[] = JSON.parse('["Straße İx", 1234.5678, {"substr": 1}, [1, 2]]'); +const [text, num, obj, list] = values; +console.log(text.codePointAt(7), text.substr(-2), text.toLocaleUpperCase(), text.toLocaleLowerCase("az")); +console.log(text.trimRight(), text.isWellFormed(), text.toLocaleLowerCase(["TR"])); +console.log(num.toPrecision(6), num.toExponential(3), num.toPrecision(), num.toExponential()); +console.log(list.at(-1), JSON.stringify(list.concat([3]))); +for (const call of [() => obj.substr(0), () => num.codePointAt(0), () => text.toPrecision(2)]) { + try { + console.log(call()); + } catch (e) { + console.log((e as Error).name + ": " + (e as Error).message); + } +} diff --git a/tests/diagnostics/dynamic-surface.ts b/tests/diagnostics/dynamic-surface.ts index 537a992eb..3af6671f4 100644 --- a/tests/diagnostics/dynamic-surface.ts +++ b/tests/diagnostics/dynamic-surface.ts @@ -4,11 +4,12 @@ // flag — never an ICE, never a link error. Static Math methods include // the scalar functions and variadic Math.hypot. The .split(string), // trim/pad variants, parseInt, isNaN, the global parseFloat/isFinite and -// Number.parseFloat/Number.parseInt over exactly-typed arguments, and -// string-pattern replacement compile statically now and no longer appear -// here.) +// Number.parseFloat/Number.parseInt over exactly-typed arguments, +// string-pattern replacement and toPrecision compile statically now and no +// longer appear here; toLocaleString without a locale still has no +// lowering.) const up = Math.cbrt(2); const tau = Math.PI * 2; -const price = (19.99).toPrecision(4); +const price = (19.99).toLocaleString(); const n = Number.parseFloat("3.14"); void n; diff --git a/tests/harness/__snapshots__/dynamic-surface.ts.txt b/tests/harness/__snapshots__/dynamic-surface.ts.txt index 2744d29e5..d4fe2ac7e 100644 --- a/tests/harness/__snapshots__/dynamic-surface.ts.txt +++ b/tests/harness/__snapshots__/dynamic-surface.ts.txt @@ -1,8 +1,8 @@ -dynamic-surface.ts:12:15 - error SC2012: '.toPrecision()' on numbers cannot compile statically +dynamic-surface.ts:13:15 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - 11 | const tau = Math.PI * 2; - 12 | const price = (19.99).toPrecision(4); - | ^~~~~~~~~~~~~~~~~~~~~~ - 13 | const n = Number.parseFloat("3.14"); + 12 | const tau = Math.PI * 2; + 13 | const price = (19.99).toLocaleString(); + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 14 | const n = Number.parseFloat("3.14"); - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file + hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt b/tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt deleted file mode 100644 index dfd5f0eaa..000000000 --- a/tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -retired-engine-1112-number-methods.ts:9:13 - error SC2012: '.toPrecision()' on numbers cannot compile statically - - 8 | console.log((1e21).toFixed(2), (0 / 0).toFixed(2)); - 9 | console.log(n.toPrecision(2), n.toPrecision(8), (0.000123).toPrecision(2)); - | ^~~~~~~~~~~~~~~~ - 10 | console.log((255).toString(16), (255).toString(2), (511).toString(8), (12345).toString(36)); - - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file From 8edd2bce8e0379c646c7ca2f39ffcac50f1a8932 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:15 -0700 Subject: [PATCH 17/86] Add crypto.getRandomValues nanoid fills its id bytes with `crypto.getRandomValues(buffer.subarray(...))`, which had no lowering. The WebCrypto global, node:crypto's export and `webcrypto.getRandomValues` now fill typed views in place and return them at their own type. Float views, DataViews and other values throw Node's TypeMismatchError DOMException, views over 65536 bytes QuotaExceededError, with Node's messages and codes. The fallback declarations gain the node:crypto export and webcrypto's member (the crypto global stays unknown in builds without @types/node). The runtime entry points are new functions reached only from these calls; the size check is integer arithmetic so no constant joins the pool every binary links. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 17 +++++++ .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/builtins/crypto.ts | 46 +++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 21 ++++++++ .../src/frontend/lowering/surfaces.ts | 2 + packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 5 ++ packages/compiler/src/ir/validate.ts | 6 ++- .../6204-crypto-get-random-values.ts.json | 6 +++ .../6205-crypto-get-random-values.js.json | 6 +++ packages/runtime/src/scr_crypto.h | 4 ++ packages/runtime/src/scr_lib.c | 49 +++++++++++++++++++ tests/corpus/6204-crypto-get-random-values.ts | 36 ++++++++++++++ tests/corpus/6205-crypto-get-random-values.js | 38 ++++++++++++++ 14 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json create mode 100644 tests/corpus/6204-crypto-get-random-values.ts create mode 100644 tests/corpus/6205-crypto-get-random-values.js diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index d72ca2dcd..1d4e11bba 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2557,6 +2557,23 @@ declare module "crypto" { a: ArrayBufferView | ArrayBufferLike, b: ArrayBufferView | ArrayBufferLike, ): boolean; + /* WebCrypto's getRandomValues (also the webcrypto object's): integer + * typed arrays, filled in place and returned. The crypto GLOBAL stays + * unknown in fallback builds. */ + type RandomValuesArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | BigInt64Array + | BigUint64Array; + export function getRandomValues(typedArray: T): T; + export const webcrypto: { + getRandomValues(typedArray: T): T; + }; export function randomFillSync( buffer: T, offset?: number, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 170ce07db..a7eecb7b3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -570,6 +570,8 @@ export const LIB_FN_SYMS: Record = { "crypto.randomIntMax": "scr_crypto_random_int_max", "crypto.randomFillView": "scr_crypto_random_fill", "crypto.randomFillViewRest": "scr_crypto_random_fill_rest", + "crypto.getRandomValues": "scr_crypto_get_random_values", + "crypto.getRandomValuesDyn": "scr_crypto_get_random_values_dyn", "crypto.randomFillSyncDyn": "scr_crypto_random_fill_sync_dyn", "crypto.randomUUIDOpts": "scr_crypto_random_uuid_opts", "crypto.createSecretKey": "scr_crypto_create_secret_key", diff --git a/packages/compiler/src/frontend/lowering/builtins/crypto.ts b/packages/compiler/src/frontend/lowering/builtins/crypto.ts index a492a0da7..e029d1c29 100644 --- a/packages/compiler/src/frontend/lowering/builtins/crypto.ts +++ b/packages/compiler/src/frontend/lowering/builtins/crypto.ts @@ -261,6 +261,7 @@ export function lowerCryptoModuleCall( if (args.some(ts.isSpreadElement)) { lowerer.noLowering(`crypto.${bi.member} with spread arguments`, expr); } + if (bi.member === "getRandomValues") return lowerCryptoGetRandomValues(lowerer, expr); if (bi.member === "randomBytes") { if (args.length === 1) return null; if (args.length !== 2) { @@ -1264,3 +1265,48 @@ export function lowerPromisifiedCryptoCall( "KeyObject", ); } + +/** `crypto.getRandomValues(view)` — the WebCrypto global, node:crypto's + * export and `webcrypto.getRandomValues`: a typed view fills in place and + * returns itself at its own type (float views, DataViews and views over + * 65536 bytes throw Node's DOMExceptions at run time); any other value + * takes the checked form. Null for other arities (the member fence keeps + * them). */ +export function lowerCryptoGetRandomValues( + lowerer: Lowerer, + call: ts.CallExpression, +): IrExpr | null { + if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) return null; + const loc = locOf(call); + const value = lowerer.lowerExpr(call.arguments[0]!); + if (value.type.kind === "bytes") + return { kind: "libCall", fn: "crypto.getRandomValues", args: [value], type: value.type, loc }; + return { + kind: "libCall", + fn: "crypto.getRandomValuesDyn", + args: [lowerer.coerceInto(call.arguments[0]!, value, DYN)], + type: DYN, + loc, + }; +} + +/** Whether `receiver` names a WebCrypto Crypto object: the `crypto` + * global (also through globalThis) or node:crypto's `webcrypto`. */ +export function isWebCryptoReceiver(lowerer: Lowerer, receiver: ts.Expression): boolean { + if (ts.isIdentifier(receiver)) { + if (receiver.text === "crypto" && lowerer.isStdlibGlobal(receiver, "crypto")) return true; + const imported = lowerer.builtinImportOf(receiver); + return imported?.module === "crypto" && imported.member === "webcrypto"; + } + if (ts.isPropertyAccessExpression(receiver) && !receiver.questionDotToken) { + if ( + receiver.name.text === "crypto" && + ts.isIdentifier(receiver.expression) && + lowerer.isStdlibGlobal(receiver.expression, "globalThis") + ) + return true; + const member = lowerer.builtinMemberOf(receiver); + return member?.module === "crypto" && member.member === "webcrypto"; + } + return false; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index ca4dd0edb..d19dd1d50 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -110,7 +110,9 @@ import { lowerRequireResolveCall, } from "./builtins/module-resolution.js"; import { + isWebCryptoReceiver, lowerCipherMethodCall, + lowerCryptoGetRandomValues, lowerCryptoHashMethodCall, lowerPromisifiedCryptoCall, } from "./builtins/crypto.js"; @@ -1403,6 +1405,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerer.fenceStaticReadableStreamMember(expr.expression, "call") ?? lowerDisposableStackUseCall(lowerer, expr, expr.expression) ?? lowerer.lowerStdlibMethodCall(expr, expr.expression) ?? + // crypto.getRandomValues on the WebCrypto global (untyped in + // fallback builds, so before the dyn receiver path) or webcrypto. + lowerWebCryptoGetRandomValues(lowerer, expr, expr.expression) ?? // Dyn receivers (JSON.parse-derived `unknown`/`any` values) — lowerDynReceiverMethodCall(lowerer, expr, expr.expression) ?? @@ -9728,3 +9733,19 @@ export function lowerStaticNumberGlobal( } return null; } + +/** `crypto.getRandomValues(view)` on the WebCrypto global or webcrypto. */ +function lowerWebCryptoGetRandomValues( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if ( + access.name.text !== "getRandomValues" || + call.questionDotToken || + access.questionDotToken || + !isWebCryptoReceiver(lowerer, access.expression) + ) + return null; + return lowerCryptoGetRandomValues(lowerer, call); +} diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index d394c1960..6bea581fe 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1521,6 +1521,7 @@ export const BUILTIN_MODULE_FNS: Record< hash: { fn: "crypto.hashDigestStr", params: [STRING, STRING, STRING], result: STRING }, timingSafeEqual: { fn: "crypto.timingSafeEqual", params: [BYTES_U8, BYTES_U8], result: BOOL }, randomFillSync: { fn: "crypto.randomFill", params: [BYTES_U8, F64, F64], result: BYTES_U8 }, + getRandomValues: { fn: "crypto.getRandomValues", params: [BYTES_U8], 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 }, @@ -2069,6 +2070,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< "crypto.randomFillPromiseDyn", ], timingSafeEqual: ["crypto.timingSafeEqualDyn"], + getRandomValues: ["crypto.getRandomValuesDyn"], randomInt: ["crypto.randomIntMax", "crypto.randomIntCb", "crypto.randomIntPromise"], randomUUID: ["crypto.randomUUIDOpts"], }, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index b537923a9..3bebd98ae 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -670,6 +670,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "crypto.randomIntMax", "crypto.randomFillView", "crypto.randomFillViewRest", + "crypto.getRandomValues", + "crypto.getRandomValuesDyn", "crypto.randomFillSyncDyn", "crypto.randomFillCb", "crypto.randomFillCbDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ab4d0ba7f..b2b6b0350 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2579,6 +2579,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * whether min was given. */ "crypto.randomFillView": { argTypes: [null, F64, F64], result: BYTES_U8 }, "crypto.randomFillViewRest": { argTypes: [null, F64], result: BYTES_U8 }, + /** crypto.getRandomValues: a view returns itself at its own type (the + * float/size DOMExceptions are thrown at run time); any other value + * takes the checked form. */ + "crypto.getRandomValues": { argTypes: [null], result: BYTES_U8 }, + "crypto.getRandomValuesDyn": { argTypes: [DYN], result: DYN }, "crypto.randomFillSyncDyn": { argTypes: [DYN, DYN, DYN], result: DYN }, "crypto.randomFillCb": { argTypes: [null, F64, F64, F64, null], result: VOID }, "crypto.randomFillCbDyn": { argTypes: [DYN, DYN, DYN, F64, null], result: VOID }, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index d10160115..68d2ca6a1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5211,7 +5211,11 @@ function validateFunction( } function checkLibSpecialCall(e: IrExpr & { kind: "libCall" }): boolean { - if (e.fn === "crypto.randomFillView" || e.fn === "crypto.randomFillViewRest") { + if ( + e.fn === "crypto.randomFillView" || + e.fn === "crypto.randomFillViewRest" || + e.fn === "crypto.getRandomValues" + ) { // The filled view is the result, at its own element type. const view = e.args[0]?.type; if (view?.kind !== "bytes" || !typeEquals(e.type, view)) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json new file mode 100644 index 000000000..86fcbcf58 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6204-crypto-get-random-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json new file mode 100644 index 000000000..42d90fd62 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6205-crypto-get-random-values.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_crypto.h b/packages/runtime/src/scr_crypto.h index 5abfeaa48..ff920a6a1 100644 --- a/packages/runtime/src/scr_crypto.h +++ b/packages/runtime/src/scr_crypto.h @@ -46,6 +46,10 @@ ScrBytes *scr_crypto_random_fill_rest(ScrBytes *bytes, double offset); double scr_crypto_random_int(double min, double max); double scr_crypto_random_int_max(double max); ScrDyn *scr_crypto_random_fill_sync_dyn(ScrDyn *buf, ScrDyn *offset, ScrDyn *size); +/* crypto.getRandomValues over a view (returned +1) or any value (the + * value itself, +1); NULL with Node's DOMException pending. */ +ScrBytes *scr_crypto_get_random_values(ScrBytes *view); +ScrDyn *scr_crypto_get_random_values_dyn(ScrDyn *value); double scr_crypto_random_fill_args(ScrBytes *view, bool typed_array, double offset, double size, int form); void scr_crypto_random_int_refill(void); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 45d9b7e2b..3c69f84d8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -11176,3 +11176,52 @@ ScrDyn *scr_dyn_native_date_from_ms(double ms) { scr_native_date_release(date); return result; } + +/* crypto.getRandomValues(typedArray) (WebCrypto, also node:crypto's + * export): integer typed arrays only (Node's TypeMismatchError DOMException + * for float arrays, DataViews and every other value), at most 65536 bytes + * (QuotaExceededError), filled in place and returned. Nothing on an + * existing path calls these. They stay at the end of the unit: clang emits + * a static helper (scr_random_write) where its first emitted caller + * appears, and an earlier caller would move it in every binary that links + * the random fills. */ +ScrBytes *scr_crypto_get_random_values(ScrBytes *view) { + static const char mismatch[] = "TypeMismatchError"; + static const char mismatch_msg[] = "The data argument must be an integer-type TypedArray"; + static const char quota[] = "QuotaExceededError"; + static const char quota_msg[] = "The requested length exceeds 65,536 bytes"; + if (view->is_data_view || view->elem == SCR_BYTES_F32 || view->elem == SCR_BYTES_F64 || + view->elem == SCR_BYTES_F16) { + scr_throw_domex(mismatch, mismatch_msg); + return NULL; + } + /* Integer arithmetic: a new floating-point literal would land in the + * merged constant pool every binary links. */ + size_t length = view->len * (size_t)scr_bytes_elem_size(view->elem); + if (length > 65536u) { + scr_throw_domex(quota, quota_msg); + return NULL; + } + scr_random_write(view, 0, length); + return scr_bytes_retain(view); +} + +ScrDyn *scr_crypto_get_random_values_dyn(ScrDyn *value) { + if (value->kind == SCR_DYN_TYPED_REF) { + ScrDyn *materialized = scr_dyn_typed_ref_materialize(value); + if (!materialized) return NULL; + ScrDyn *result = scr_crypto_get_random_values_dyn(materialized); + scr_dyn_release(materialized); + return result ? scr_dyn_retain(value) : NULL; + } + if (value->kind != SCR_DYN_BYTES) { + static const char mismatch[] = "TypeMismatchError"; + static const char mismatch_msg[] = "The data argument must be an integer-type TypedArray"; + scr_throw_domex(mismatch, mismatch_msg); + return NULL; + } + ScrBytes *filled = scr_crypto_get_random_values(value->v.bytes); + if (!filled) return NULL; + scr_bytes_release(filled); + return scr_dyn_retain(value); +} diff --git a/tests/corpus/6204-crypto-get-random-values.ts b/tests/corpus/6204-crypto-get-random-values.ts new file mode 100644 index 000000000..fae3c4049 --- /dev/null +++ b/tests/corpus/6204-crypto-get-random-values.ts @@ -0,0 +1,36 @@ +// crypto.getRandomValues through node:crypto's export and webcrypto: +// integer typed arrays fill in place and come back as the same object; +// views over 65536 bytes throw QuotaExceededError. (The global spelling and +// the float/DataView TypeMismatchError are 6205's.) +import { getRandomValues, webcrypto } from "node:crypto"; +import * as nodeCrypto from "node:crypto"; + +const bytes = new Uint8Array(4096); +const same = webcrypto.getRandomValues(bytes); +function nonzero(view: Uint8Array): boolean { + for (let i = 0; i < view.length; i++) if (view[i] !== 0) return true; + return false; +} +function nonzeroWords(view: Uint32Array): boolean { + for (let i = 0; i < view.length; i++) if (view[i] !== 0) return true; + return false; +} +console.log(same === bytes, bytes.length, nonzero(bytes)); +const words = new Uint32Array(256); +console.log(getRandomValues(words) === words, nonzeroWords(words)); +const shorts = new Int16Array(8); +console.log(webcrypto.getRandomValues(shorts) === shorts, nodeCrypto.getRandomValues(new Uint8ClampedArray(3)).length); +console.log(nodeCrypto.webcrypto.getRandomValues(new Int8Array(65536)).length, getRandomValues(new Uint8Array(0)).length); +const buf = Buffer.alloc(16); +console.log(Buffer.isBuffer(getRandomValues(buf)), buf.subarray(4, 8).length); + +function attempt(label: string, f: () => unknown): void { + try { + f(); + console.log(label, "ok"); + } catch (e) { + console.log(label, (e as DOMException).name, (e as DOMException).code, (e as Error).message, e instanceof DOMException); + } +} +attempt("big", () => getRandomValues(new Uint8Array(65537))); +attempt("big32", () => webcrypto.getRandomValues(new Uint32Array(16385))); diff --git a/tests/corpus/6205-crypto-get-random-values.js b/tests/corpus/6205-crypto-get-random-values.js new file mode 100644 index 000000000..97d3e67d5 --- /dev/null +++ b/tests/corpus/6205-crypto-get-random-values.js @@ -0,0 +1,38 @@ +// nanoid's fillRandom: crypto.getRandomValues over Buffer windows of an +// untyped helper, plus non-view values that throw TypeMismatchError. +import { getRandomValues } from "node:crypto"; +const GET_RANDOM_LIMIT = 65536; +function fillRandom(buffer) { + let from = 0; + while (from < buffer.length) { + const to = Math.min(from + GET_RANDOM_LIMIT, buffer.length); + crypto.getRandomValues(buffer.subarray(from, to)); + from = to; + } +} +const alphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; +function nanoid(size = 21) { + const bytes = Buffer.allocUnsafe(size); + fillRandom(bytes); + let id = ""; + for (let i = 0; i < size; i++) id += alphabet[bytes[i] & 63]; + return id; +} +const id = nanoid(); +console.log(id.length, /^[A-Za-z0-9_-]+$/.test(id), nanoid(10).length, nanoid(70000).length); +const filled = globalThis.crypto.getRandomValues(new Uint16Array(3)); +console.log(filled.length); +for (const value of [new Float64Array(1), new Float32Array(2), new DataView(new ArrayBuffer(2)), new Uint8Array(65537), [1, 2], "x", 5, {}]) { + try { + crypto.getRandomValues(value); + console.log("ok"); + } catch (e) { + console.log(e.name, e.message); + } +} +const untyped = JSON.parse("[1]"); +try { + getRandomValues(untyped); +} catch (e) { + console.log(e.name, e.code, e.message); +} From 9e63a937db52bafd616ce56aaa8bd0e4f201c06f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:18 -0700 Subject: [PATCH 18/86] Test the Buffer brand at run time and alias the buffer module's Buffer `Buffer.isBuffer` over a Uint8Array-typed value (safe-buffer's `Buffer.from(...)` result, etag's input) refused at compile time because the bytes IR shares storage between Buffer and Uint8Array. The view keeps its Buffer brand at run time, so the test now reads it. `require('buffer').Buffer`, `const { Buffer } = require('buffer')` and `buffer.Buffer` through a namespace binding alias the global Buffer (the module member is the same constructor), so their statics lower like the global's. A file-scope `var` of the namespace form aliases too when nothing reassigns it and the require-binding early-reference analysis finds no earlier reader. `Buffer.from` over a JS-inferred `string | Uint8Array | undefined` value takes the checked runtime form. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/containers/bytes.ts | 44 ++++++++++++++++++- .../src/frontend/lowering/lower-modules.ts | 2 + .../src/frontend/lowering/lower-stmts.ts | 25 +++++++++++ .../src/frontend/lowering/surfaces.ts | 30 +++++++++++-- .../compiler/src/ir/builtin-signatures.ts | 3 ++ .../6206-buffer-is-buffer-views.ts.json | 6 +++ .../corpus/6209-buffer-module-alias.cjs.json | 6 +++ packages/runtime/src/scr_bytes.c | 3 ++ packages/runtime/src/scr_bytes.h | 2 + tests/corpus/6206-buffer-is-buffer-views.ts | 16 +++++++ tests/corpus/6209-buffer-module-alias.cjs | 25 +++++++++++ 12 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json create mode 100644 tests/corpus/6206-buffer-is-buffer-views.ts create mode 100644 tests/corpus/6209-buffer-module-alias.cjs diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index a7eecb7b3..749a68e9f 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -406,6 +406,7 @@ export const LIB_FN_SYMS: Record = { "buffer.fromStr": "scr_bytes_from_str", "buffer.fromStrChecked": "scr_bytes_from_str_checked", "buffer.brand": "scr_bytes_as_buffer", + "buffer.isBranded": "scr_bytes_is_buffer", "buffer.fromDyn": "scr_buffer_from_dyn", "buffer.concat": "scr_buffer_concat", "buffer.concatLen": "scr_buffer_concat_len", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 7a19927de..235863efb 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -20,6 +20,7 @@ import { arrayOf, bytesOf, funcOf, + isUnitType, typeEquals, } from "../../../ir/ir.js"; import { isJsSourceFile, locOf } from "../../program.js"; @@ -1408,7 +1409,14 @@ function lowerBufferStaticValue( } return { kind: "libCall", fn, args: [s, enc], type: BYTES_U8, loc }; } - if (args.length === 1 && srcIr?.kind === "bytes" && srcIr.elem === "u8") { + // A checker type with no mapping (pako's conditional `O extends + // { toText: true } ? string : Uint8Array` result) whose lowered + // value is a u8 view copies like the typed form. + if ( + args.length === 1 && + ((srcIr?.kind === "bytes" && srcIr.elem === "u8") || + (srcIr === null && value.type.kind === "bytes" && value.type.elem === "u8")) + ) { return { kind: "libCall", fn: "buffer.poolFromBytes", @@ -1426,6 +1434,34 @@ function lowerBufferStaticValue( loc, }; } + // A union of strings, u8 views and undefined/null (a JS-inferred + // npm result such as pako's deflate): the checked form dispatches + // on the runtime kind exactly as for an untyped JS value, Node's + // TypeError for the unit arms included. + if ( + args.length === 1 && + value.type.kind === "union" && + lowerer.dynConvertible(value.type) && + lowerer.unions + .get(value.type.unionId) + ?.arms.every( + (arm) => + arm.kind === "string" || + (arm.kind === "bytes" && arm.elem === "u8") || + isUnitType(arm), + ) === true + ) { + return { + kind: "libCall", + fn: "buffer.poolFromDyn", + args: [ + lowerer.coerceToExpected(value, DYN), + { kind: "strLit", value: "utf8", type: STRING, loc }, + ], + type: BYTES_U8, + loc, + }; + } } } lowerer.noLowering( @@ -1626,6 +1662,12 @@ function lowerBufferStaticValue( }; } } + // A typed view of unknown provenance (a Uint8Array parameter, + // safe-buffer's Buffer.from result): Buffer is a brand of the view, + // which the runtime keeps, so the test reads it. + if (v.type.kind === "bytes") { + return { kind: "libCall", fn: "buffer.isBranded", args: [v], type: BOOL, loc }; + } lowerer.noLowering( `Buffer.isBuffer of '${lowerer.fmt(v.type)}' values`, args[0]!, diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3baab9dd0..0edb586bc 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -102,6 +102,7 @@ import { isVarDeclared, numericIteratorSourceOf, provenanceElidedConstDecl, + stdlibBufferVarAlias, } from "./lower-stmts.js"; import { objectFactoryGlobalType } from "./object-factory-new.js"; import { streamClassAliasDecl } from "./lower-stream.js"; @@ -1805,6 +1806,7 @@ export function collectGlobals( // same test). const stableStdlibAlias = isConst || + stdlibBufferVarAlias(lowerer, decl) || ((list.flags & ts.NodeFlags.Let) !== 0 && ts.isIdentifier(decl.name) && stdlibGlobalAliasNameOf(lowerer, decl.initializer) !== null && diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 0f1b606ef..dba32b854 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -67,6 +67,7 @@ import { isJsSourceFile, isNodeEsmFile, locOf, + requireEarlyReference, requireSpecOf, } from "../program.js"; import { @@ -78,6 +79,7 @@ import { sideEffectFreeOptionValue, stdlibGlobalAliasDecl, stdlibGlobalAliasNameOf, + bufferNamespaceMember, stdlibGlobalNameOf, } from "./surfaces.js"; import { isNativeBuiltinValueInitializer } from "./lower-builtin-values.js"; @@ -4385,6 +4387,7 @@ export function lowerVarDecl( // alias plumbing (see stdlibGlobalAliasDecl), no storage, no code. const stableStdlibAlias = !isLet || + stdlibBufferVarAlias(lowerer, decl) || (ts.isVariableDeclarationList(decl.parent) && (decl.parent.flags & ts.NodeFlags.Let) !== 0 && ts.isIdentifier(decl.name) && @@ -10521,3 +10524,25 @@ export function lowerForStatement(lowerer: Lowerer, stmt: ts.ForStatement): IrSt lowerer.scopes.pop(); } } + +/** safe-buffer's `var Buffer = buffer.Buffer` at file scope: a `var` alias + * of the global Buffer is the same alias plumbing as the const spelling + * when nothing reassigns it and no code that runs before the declaration + * can read it (the require-binding early-reference analysis), so the + * hoisted undefined is unobservable. */ +export function stdlibBufferVarAlias(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if ( + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0 || + decl.initializer === undefined || + !bufferNamespaceMember(lowerer, decl.initializer) + ) + return false; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + return ( + symbol !== undefined && + bindingNeverReassigned(lowerer, symbol, decl) && + requireEarlyReference(lowerer.program, decl) === null + ); +} diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6bea581fe..6d7da7b38 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -22,7 +22,7 @@ import { VOID, arrayOf, } from "../../ir/ir.js"; -import { isNodeTypesPath, requireSpecOf } from "../program.js"; +import { isJsSourceFile, isNodeTypesPath, requireSpecOf } from "../program.js"; import { ZLIB_CONSTANTS } from "./zlib-constants.js"; /** zlib stream instances: runtime Transform streams (scr_zlib_stream.c). */ @@ -3243,7 +3243,21 @@ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): strin // `Buffer` resolves to a user-file declaration and still misses. const resolved = symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol; - if (!lowerer.isStdlibSymbol(resolved)) return null; + if (!lowerer.isStdlibSymbol(resolved)) { + // A CommonJS binding of the buffer module's Buffer (`var Buffer = + // require('buffer').Buffer`, `const { Buffer } = require('buffer')` + // — safe-buffer and compression): the module member IS the global + // constructor. + if ( + decl !== undefined && + (ts.isVariableDeclaration(decl) || ts.isBindingElement(decl)) && + isJsSourceFile(decl.getSourceFile()) + ) { + const imported = lowerer.builtinImportOf(expr); + if (imported?.module === "buffer" && imported.member === "Buffer") return "Buffer"; + } + return null; + } return resolved.name === "global" ? "globalThis" : resolved.name; } if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { @@ -3318,7 +3332,9 @@ export function stdlibGlobalAliasNameOf( const name = requireSpec === "process" || requireSpec === "node:process" ? "process" - : stdlibGlobalNameOf(lowerer, aliasExpr); + : bufferNamespaceMember(lowerer, aliasExpr) + ? "Buffer" + : stdlibGlobalNameOf(lowerer, aliasExpr); if (name === null) return null; // These globals have provenance-aware call or member lowering, so a // stable snapshot keeps the same dispatch as the original global. @@ -3339,6 +3355,14 @@ export function stdlibGlobalAliasNameOf( : null; } +/** `buffer.Buffer` through a buffer-module namespace binding (`var buffer + * = require('buffer')`): the module's Buffer IS the global constructor. */ +export function bufferNamespaceMember(lowerer: Lowerer, expr: ts.Expression): boolean { + if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== "Buffer") return false; + const member = lowerer.builtinMemberOf(expr); + return member?.module === "buffer" && member.member === "Buffer"; +} + export function stdlibGlobalAliasDecl( lowerer: Lowerer, nameNode: ts.Node, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index b2b6b0350..748029589 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2634,6 +2634,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "buffer.fromStr": { argTypes: [STRING, STRING], result: BYTES_U8 }, "buffer.fromStrChecked": { argTypes: [STRING, STRING], result: BYTES_U8 }, "buffer.brand": { argTypes: [BYTES_U8], result: BYTES_U8 }, + /** Buffer.isBuffer over a typed view: whether the view carries the + * Buffer brand (any element kind; only Buffers do). Never throws. */ + "buffer.isBranded": { argTypes: [null], result: BOOL }, /** Buffer.from on checked-native strings, bytes, arrays and data-only * array-like/Buffer-JSON objects. The encoding is a normalized literal; * non-string inputs ignore it. Copies the input and may throw during diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json new file mode 100644 index 000000000..7a9c25279 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6206-buffer-is-buffer-views.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json new file mode 100644 index 000000000..3d3c194b2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6209-buffer-module-alias.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 28258a29c..01a3be609 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -293,6 +293,9 @@ ScrBytes *scr_bytes_as_buffer(ScrBytes *bytes) { return scr_bytes_retain(bytes); } +/* Nothing on an existing path calls this. */ +bool scr_bytes_is_buffer(const ScrBytes *bytes) { return bytes->is_buffer; } + void scr_bytes_release(ScrBytes *b) { if (!b || b->rc == SIZE_MAX) return; /* NULL: an uninitialized `let` local */ if (--b->rc == 0) { diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4309154c9..bdc5e5615 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -159,6 +159,8 @@ ScrBytes *scr_bytes_new(ScrBytesElem elem, double n); /* +1 */ /* Shared u8 view over exactly the source view's raw bytes. Borrows; +1. */ ScrBytes *scr_bytes_raw_view(ScrBytes *bytes); ScrBytes *scr_bytes_as_buffer(ScrBytes *bytes); /* borrows; +1, Buffer factory only */ +/* Buffer.isBuffer over a typed view: the view's Buffer brand. Borrows. */ +bool scr_bytes_is_buffer(const ScrBytes *bytes); /* Views retain the root allocation; offsets are relative to its complete * byte storage, including when src is itself a subarray. */ ScrBytes *scr_bytes_buffer_view(ScrBytes *src, ScrBytesElem elem, diff --git a/tests/corpus/6206-buffer-is-buffer-views.ts b/tests/corpus/6206-buffer-is-buffer-views.ts new file mode 100644 index 000000000..55e9fd49c --- /dev/null +++ b/tests/corpus/6206-buffer-is-buffer-views.ts @@ -0,0 +1,16 @@ +// Buffer.isBuffer over Uint8Array-typed values reads the view's Buffer +// brand: Buffer factories, Buffer subarrays and slices are Buffers, plain +// typed arrays and copies into them are not. +function check(view: Uint8Array): boolean { + return Buffer.isBuffer(view); +} +const plain = new Uint8Array([1, 2, 3]); +const buffer = Buffer.from(plain); +console.log(check(plain), check(buffer), check(Buffer.alloc(2)), check(Buffer.from("hey").subarray(1))); +console.log(check(plain.subarray(1)), check(new Uint8Array(buffer)), check(buffer.slice(1)), check(Buffer.concat([plain]))); +const views: Uint8Array[] = [plain, buffer, new TextEncoder().encode("x"), Buffer.allocUnsafe(1)]; +console.log(views.map((v) => Buffer.isBuffer(v)).join(",")); +function words(view: Uint32Array): boolean { + return Buffer.isBuffer(view); +} +console.log(words(new Uint32Array(2)), Buffer.from(plain).toString("hex"), Buffer.from(views[2]!).toString()); diff --git a/tests/corpus/6209-buffer-module-alias.cjs b/tests/corpus/6209-buffer-module-alias.cjs new file mode 100644 index 000000000..cec2d06d8 --- /dev/null +++ b/tests/corpus/6209-buffer-module-alias.cjs @@ -0,0 +1,25 @@ +// The buffer module's Buffer through CommonJS bindings — `var Buffer = +// require('buffer').Buffer` and a member of a stored require — is the +// global Buffer constructor, with its statics. +var Buffer = require("buffer").Buffer; +var buffer = require("buffer"); +var B2 = buffer.Buffer; +const B3 = require("node:buffer").Buffer; +console.log(Buffer.from("hi").toString("hex"), Buffer.isBuffer(Buffer.alloc(1)), B2.byteLength("héllo"), B2.isBuffer("x")); +console.log(B3.concat([B2.from("a"), Buffer.from([98])]).toString(), B3.alloc(2, 1).toString("base64")); +function toBuffer(chunk) { + return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); +} +console.log(toBuffer("abc").length, toBuffer(Buffer.from("xyz")).toString()); + +// Buffer.from over a JS-inferred `string | Uint8Array | undefined` result +// dispatches on the runtime kind, Node's TypeError for undefined included. +function make(flag) { + return flag ? "ab" : flag === false ? new Uint8Array([99, 100]) : undefined; +} +console.log(Buffer.from(make(true)).toString(), Buffer.from(make(false)).toString("hex")); +try { + Buffer.from(make(null)); +} catch (e) { + console.log(e.name, e.code); +} From ffd13d97510d6756a2907731094ac0413bc8f694 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:21 -0700 Subject: [PATCH 19/86] Read fs.constants from the target libc Only the access-mode and COPYFILE bits of fs.constants lowered; every other member and the object itself refused. The runtime now builds node_constants.cc's fs group in its order under the target libc's #ifdef guards (O_NOATIME and O_DIRECT on Linux, O_SYMLINK on macOS) as a cached null-prototype object with read-only members. `fs.constants` as a value reads it, and members beyond the baked literals read from it (undefined for a constant the target lacks, in JavaScript sources). The fallback declarations list the full set. The table keeps each name inside its entry, so the whole table is one data object section GC drops. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 41 +++++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../frontend/lowering/builtins/constants.ts | 36 +++- .../src/frontend/lowering/lower-exprs.ts | 2 + .../compiler/src/frontend/lowering/lowerer.ts | 20 +-- .../src/frontend/lowering/surfaces.ts | 9 + .../compiler/src/ir/builtin-signatures.ts | 2 + .../corpus/6207-fs-constants-object.ts.json | 6 + .../corpus/6208-fs-constants-require.cjs.json | 6 + packages/runtime/src/scr_os.c | 170 ++++++++++++++++++ packages/runtime/src/scr_os.h | 2 + tests/corpus/6207-fs-constants-object.ts | 16 ++ tests/corpus/6208-fs-constants-require.cjs | 8 + 13 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json create mode 100644 tests/corpus/6207-fs-constants-object.ts create mode 100644 tests/corpus/6208-fs-constants-require.cjs diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 1d4e11bba..ff7921e73 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1493,6 +1493,47 @@ declare module "node:fs" { readonly COPYFILE_EXCL: number; readonly COPYFILE_FICLONE: number; readonly COPYFILE_FICLONE_FORCE: number; + readonly UV_FS_SYMLINK_DIR: number; + readonly UV_FS_SYMLINK_JUNCTION: number; + readonly UV_DIRENT_UNKNOWN: number; + readonly UV_DIRENT_FILE: number; + readonly UV_DIRENT_DIR: number; + readonly UV_DIRENT_LINK: number; + readonly UV_DIRENT_FIFO: number; + readonly UV_DIRENT_SOCKET: number; + readonly UV_DIRENT_CHAR: number; + readonly UV_DIRENT_BLOCK: number; + readonly UV_FS_O_FILEMAP: number; + readonly UV_FS_COPYFILE_EXCL: number; + readonly UV_FS_COPYFILE_FICLONE: number; + readonly UV_FS_COPYFILE_FICLONE_FORCE: number; + readonly S_IFMT: number; + readonly S_IFREG: number; + readonly S_IFDIR: number; + readonly S_IFCHR: number; + readonly S_IFBLK: number; + readonly S_IFIFO: number; + readonly S_IFLNK: number; + readonly S_IFSOCK: number; + readonly O_NOCTTY: number; + readonly O_DIRECTORY: number; + readonly O_NOATIME: number; + readonly O_SYNC: number; + readonly O_DSYNC: number; + readonly O_SYMLINK: number; + readonly O_DIRECT: number; + readonly S_IRWXU: number; + readonly S_IRUSR: number; + readonly S_IWUSR: number; + readonly S_IXUSR: number; + readonly S_IRWXG: number; + readonly S_IRGRP: number; + readonly S_IWGRP: number; + readonly S_IXGRP: number; + readonly S_IRWXO: number; + readonly S_IROTH: number; + readonly S_IWOTH: number; + readonly S_IXOTH: number; }; /* A stat(2) snapshot (statSync follows symlinks, lstatSync does not — * Node's split) — immutable; the supported surface is exactly these diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 749a68e9f..210881932 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -198,6 +198,7 @@ export const LIB_FN_SYMS: Record = { "os.cpusModel": "scr_os_cpus_model", "os.cpusField": "scr_os_cpus_field", "os.constants": "scr_os_constants", + "fs.constants": "scr_fs_constants", "os.release": "scr_os_release", "os.userName": "scr_os_user_name", "os.userShell": "scr_os_user_shell", diff --git a/packages/compiler/src/frontend/lowering/builtins/constants.ts b/packages/compiler/src/frontend/lowering/builtins/constants.ts index 0325a0ef7..b435f4634 100644 --- a/packages/compiler/src/frontend/lowering/builtins/constants.ts +++ b/packages/compiler/src/frontend/lowering/builtins/constants.ts @@ -1,11 +1,11 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer, own } from "../lowerer.js"; -import { canonicalBuiltinModule, locOf, requireSpecOf } from "../../program.js"; +import { canonicalBuiltinModule, isJsSourceFile, locOf, requireSpecOf } from "../../program.js"; import { builtinConstLit } from "../surfaces.js"; import { HTTP2_CONSTANTS } from "../http2-constants.js"; import { CRYPTO_CONSTANTS } from "../crypto-tables.js"; import { ZLIB_CONSTANTS } from "../zlib-constants.js"; -import { F64, type IrExpr } from "../../../ir/ir.js"; +import { DYN, F64, type IrExpr, STRING, type SrcLoc } from "../../../ir/ir.js"; import { builtinImportOf } from "./module-bindings.js"; /** The builtin modules whose `constants` object bakes as literals at @@ -147,12 +147,30 @@ export function lowerFsConstantsProperty( COPYFILE_FICLONE_FORCE: 4, }; const value = own(MODES, expr.name.text); - if (value === undefined) { - lowerer.noLowering( - `fs.constants.${expr.name.text}`, - expr, - "F_OK, R_OK, W_OK, X_OK, and COPYFILE_* are the lowered constants", - ); - } + if (value === undefined) return fsConstantMemberRead(expr); return { kind: "numLit", value, type: F64, loc: locOf(expr) }; } + +/** fs.constants as a value: the target's null-prototype constants object, + * built by the runtime from its own libc's macros. */ +export function fsConstantsObject(loc: SrcLoc): IrExpr { + return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc }; +} + +/** `fs.constants.O_RDONLY` (any member the access-mode literals do not + * cover): a read of the runtime object, so a constant the target lacks + * reads as undefined in JavaScript and fails the number check in + * TypeScript, whose declarations promise every name. */ +export function fsConstantMemberRead(expr: ts.PropertyAccessExpression): IrExpr { + const loc = locOf(expr); + const read: IrExpr = { + kind: "dynKeyGet", + value: fsConstantsObject(loc), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }; + return isJsSourceFile(expr.getSourceFile()) + ? read + : { kind: "dynCheck", value: read, type: F64, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d4d03591d..8c8b1c09b 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1253,6 +1253,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (bi.module === "os" && bi.member === "constants") { return { kind: "libCall", fn: "os.constants", args: [], type: DYN, loc }; } + if (bi.module === "fs" && bi.member === "constants") + return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc }; // tls.rootCertificates: a runtime-valued module constant (the // cached bundled-CA array) — the one member read that lowers // to a libCall instead of a baked literal. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index bf4ed5464..65f1efbe9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -444,6 +444,8 @@ import { lowerConsoleNew } from "./lower-console.js"; import { lowerNumberStaticCall, lowerNumberStaticProperty } from "./builtins/numbers.js"; import { dateJsonType, isDateJsonSource, lowerDateCall } from "./builtins/dates.js"; import { + fsConstantMemberRead, + fsConstantsObject, lowerFsConstantsProperty, lowerBuiltinConstantsProperty, builtinConstantBindingOf, @@ -11685,13 +11687,7 @@ export class Lowerer { COPYFILE_FICLONE_FORCE: 4, }; const value = own(MODES, expr.name.text); - if (value === undefined) { - this.noLowering( - `fs.constants.${expr.name.text}`, - expr, - "F_OK, R_OK, W_OK, X_OK, and COPYFILE_* are the lowered constants", - ); - } + if (value === undefined) return fsConstantMemberRead(expr); return { kind: "numLit", value, type: F64, loc }; } } @@ -11751,14 +11747,8 @@ export class Lowerer { const callable = this.lowerBuiltinCallableValue(bi, loc); if (callable) return callable; } - if (bi.member === "constants" && bi.module === "fs") { - // A bare `fs.constants` read (not one of the baked bits above). - this.noLowering( - `fs.constants`, - expr, - "F_OK, R_OK, W_OK, X_OK, and COPYFILE_* are the lowered constants", - ); - } + // A bare `fs.constants` read (not one of the baked bits above). + if (bi.member === "constants" && bi.module === "fs") return fsConstantsObject(loc); if (builtinModuleFnOf(this, bi.module, bi.member)) { this.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6d7da7b38..ef03fa65e 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2622,6 +2622,15 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ fns: ["os.constants"], note: "the platform's dlopen, errno, signals and priority groups; members a platform lacks read as undefined", }, + // fs.constants as a value, and its members beyond the baked access-mode + // bits: the target libc's null-prototype constants object. + { + id: "node-builtin.fs.constants", + kind: "node-builtin", + name: "fs.constants", + fns: ["fs.constants"], + note: "node_constants.cc's fs group under the target libc's macros; members a platform lacks read as undefined", + }, ]; /** The win32-target overrides of the bare modules' constants: path's diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 748029589..634ca33af 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -563,6 +563,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** os.constants: the per-thread null-prototype constants object (dlopen, * errno, signals, priority, UV_UDP_REUSEADDR). Never throws. */ "os.constants": { argTypes: [], result: DYN }, + /** fs.constants: the target's cached null-prototype constants object. */ + "fs.constants": { argTypes: [], result: DYN }, /** umask(2): arg < 0 reads without setting (umask has no read-only form * — set 0, restore); otherwise sets and answers the PREVIOUS mask. * Never throws. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json new file mode 100644 index 000000000..6f24442b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6207-fs-constants-object.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json new file mode 100644 index 000000000..eac8fa544 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6208-fs-constants-require.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index 76f69588e..bebdb2dc0 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -16,6 +16,7 @@ #include #include #include +#include #ifdef _WIN32 #include /* BEFORE windows.h (which pulls winsock 1 otherwise) */ @@ -1332,3 +1333,172 @@ ScrDyn *scr_os_constants(void) { scr_atexit(scr_os_constants_clear); return scr_dyn_retain(os); } + +/* fs.constants: node_constants.cc's fs group, in its order and under its + * #ifdef guards, so each target answers its own libc's set and values + * (O_NOATIME/O_DIRECT on Linux, O_SYMLINK on macOS). The names live inside + * the table entries, so the table is one data object that section GC drops + * from binaries that never read fs.constants. Nothing on an existing path + * calls this. */ +typedef struct { + char name[32]; + double value; +} ScrFsConstant; +#define SCR_FS_C(n) {#n, (double)(n)} +static const ScrFsConstant scr_fs_constant_table[] = { + {"UV_FS_SYMLINK_DIR", 1}, + {"UV_FS_SYMLINK_JUNCTION", 2}, +#ifdef O_RDONLY + SCR_FS_C(O_RDONLY), +#endif +#ifdef O_WRONLY + SCR_FS_C(O_WRONLY), +#endif +#ifdef O_RDWR + SCR_FS_C(O_RDWR), +#endif + {"UV_DIRENT_UNKNOWN", 0}, + {"UV_DIRENT_FILE", 1}, + {"UV_DIRENT_DIR", 2}, + {"UV_DIRENT_LINK", 3}, + {"UV_DIRENT_FIFO", 4}, + {"UV_DIRENT_SOCKET", 5}, + {"UV_DIRENT_CHAR", 6}, + {"UV_DIRENT_BLOCK", 7}, +#ifdef S_IFMT + SCR_FS_C(S_IFMT), +#endif +#ifdef S_IFREG + SCR_FS_C(S_IFREG), +#endif +#ifdef S_IFDIR + SCR_FS_C(S_IFDIR), +#endif +#ifdef S_IFCHR + SCR_FS_C(S_IFCHR), +#endif +#ifdef S_IFBLK + SCR_FS_C(S_IFBLK), +#endif +#ifdef S_IFIFO + SCR_FS_C(S_IFIFO), +#endif +#ifdef S_IFLNK + SCR_FS_C(S_IFLNK), +#endif +#ifdef S_IFSOCK + SCR_FS_C(S_IFSOCK), +#endif +#ifdef O_CREAT + SCR_FS_C(O_CREAT), +#endif +#ifdef O_EXCL + SCR_FS_C(O_EXCL), +#endif + {"UV_FS_O_FILEMAP", 0}, +#ifdef O_NOCTTY + SCR_FS_C(O_NOCTTY), +#endif +#ifdef O_TRUNC + SCR_FS_C(O_TRUNC), +#endif +#ifdef O_APPEND + SCR_FS_C(O_APPEND), +#endif +#ifdef O_DIRECTORY + SCR_FS_C(O_DIRECTORY), +#endif +#ifdef O_NOATIME + SCR_FS_C(O_NOATIME), +#endif +#ifdef O_NOFOLLOW + SCR_FS_C(O_NOFOLLOW), +#endif +#ifdef O_SYNC + SCR_FS_C(O_SYNC), +#endif +#ifdef O_DSYNC + SCR_FS_C(O_DSYNC), +#endif +#ifdef O_SYMLINK + SCR_FS_C(O_SYMLINK), +#endif +#ifdef O_DIRECT + SCR_FS_C(O_DIRECT), +#endif +#ifdef O_NONBLOCK + SCR_FS_C(O_NONBLOCK), +#endif +#ifdef S_IRWXU + SCR_FS_C(S_IRWXU), +#endif +#ifdef S_IRUSR + SCR_FS_C(S_IRUSR), +#endif +#ifdef S_IWUSR + SCR_FS_C(S_IWUSR), +#endif +#ifdef S_IXUSR + SCR_FS_C(S_IXUSR), +#endif +#ifdef S_IRWXG + SCR_FS_C(S_IRWXG), +#endif +#ifdef S_IRGRP + SCR_FS_C(S_IRGRP), +#endif +#ifdef S_IWGRP + SCR_FS_C(S_IWGRP), +#endif +#ifdef S_IXGRP + SCR_FS_C(S_IXGRP), +#endif +#ifdef S_IRWXO + SCR_FS_C(S_IRWXO), +#endif +#ifdef S_IROTH + SCR_FS_C(S_IROTH), +#endif +#ifdef S_IWOTH + SCR_FS_C(S_IWOTH), +#endif +#ifdef S_IXOTH + SCR_FS_C(S_IXOTH), +#endif +#ifdef F_OK + SCR_FS_C(F_OK), +#endif +#ifdef R_OK + SCR_FS_C(R_OK), +#endif +#ifdef W_OK + SCR_FS_C(W_OK), +#endif +#ifdef X_OK + SCR_FS_C(X_OK), +#endif + {"UV_FS_COPYFILE_EXCL", 1}, + {"COPYFILE_EXCL", 1}, + {"UV_FS_COPYFILE_FICLONE", 2}, + {"COPYFILE_FICLONE", 2}, + {"UV_FS_COPYFILE_FICLONE_FORCE", 4}, + {"COPYFILE_FICLONE_FORCE", 4}, +}; +#undef SCR_FS_C + +static SCR_TL ScrDyn *scr_fs_constants_value; +static void scr_fs_constants_clear(void) { + ScrDyn *value = scr_fs_constants_value; + scr_fs_constants_value = NULL; + scr_dyn_release(value); +} + +ScrDyn *scr_fs_constants(void) { + if (scr_fs_constants_value) return scr_dyn_retain(scr_fs_constants_value); + ScrDyn *o = scr_dyn_new_obj_null_proto(); + for (size_t i = 0; i < sizeof scr_fs_constant_table / sizeof scr_fs_constant_table[0]; i++) + scr_os_constant(o, scr_fs_constant_table[i].name, scr_fs_constant_table[i].value); + scr_fs_constants_value = o; + scr_atexit(scr_fs_constants_clear); + return scr_dyn_retain(o); +} diff --git a/packages/runtime/src/scr_os.h b/packages/runtime/src/scr_os.h index 3c5009fc5..835f2b052 100644 --- a/packages/runtime/src/scr_os.h +++ b/packages/runtime/src/scr_os.h @@ -38,6 +38,8 @@ ScrStr *scr_os_cpus_model(double i); double scr_os_cpus_field(double i, double field); /* os.constants: the per-thread null-prototype constants object (+1). */ ScrDyn *scr_os_constants(void); +/* fs.constants: the target's null-prototype constants object (cached, +1). */ +ScrDyn *scr_fs_constants(void); double scr_os_available_parallelism(void); /* uv_available_parallelism: CPUs this process may use, >= 1 */ /* os.userInfo()'s field trio (pw_name / pw_shell / pw_dir — the passwd * home, not the $HOME cascade). +1 fresh; abort on lookup failure. */ diff --git a/tests/corpus/6207-fs-constants-object.ts b/tests/corpus/6207-fs-constants-object.ts new file mode 100644 index 000000000..e329009c1 --- /dev/null +++ b/tests/corpus/6207-fs-constants-object.ts @@ -0,0 +1,16 @@ +// fs.constants as a value and every member: the target platform's flags, +// file-type bits and permission bits, in Node's order, on a null-prototype +// object whose members are read-only. The access-mode bits keep their +// baked literals. +import * as fs from "node:fs"; +import { constants } from "node:fs"; + +console.log(JSON.stringify(Object.entries(fs.constants))); +console.log(fs.constants.O_RDONLY, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, constants.S_IFMT, constants.S_IFDIR); +console.log(constants.W_OK, fs.constants.F_OK, constants.COPYFILE_EXCL, fs.constants.UV_FS_O_FILEMAP); +const c: unknown = fs.constants; +console.log(typeof c, c === (fs.constants as unknown), Object.getPrototypeOf(c as object) === null, Object.keys(fs.constants).length > 40); +const mode = fs.statSync(".").mode; +console.log((mode & constants.S_IFMT) === constants.S_IFDIR, (mode & fs.constants.S_IRUSR) !== 0); +const descriptor = Object.getOwnPropertyDescriptor(c as object, "O_RDONLY"); +console.log(JSON.stringify(descriptor)); diff --git a/tests/corpus/6208-fs-constants-require.cjs b/tests/corpus/6208-fs-constants-require.cjs new file mode 100644 index 000000000..c8488a8bf --- /dev/null +++ b/tests/corpus/6208-fs-constants-require.cjs @@ -0,0 +1,8 @@ +// graceful-fs's reads of require('fs').constants: own-property probes for +// platform-specific flags and member reads through a stored binding. +var fs = require("fs"); +var constants = fs.constants; +console.log(typeof constants.O_RDONLY, constants.O_RDWR, Object.prototype.hasOwnProperty.call(constants, "O_SYMLINK") === ("O_SYMLINK" in constants), typeof constants.hasOwnProperty); +console.log(Object.prototype.hasOwnProperty.call(constants, "S_IFREG"), constants.O_EXCL > 0, typeof require("fs").constants.S_IFLNK); +var names = Object.keys(constants).filter(function (k) { return k.indexOf("UV_DIRENT_") === 0; }); +console.log(names.join(","), constants.NOT_A_CONSTANT); From 5737483a0480d27656a8e87d4c9eaa7327d87474 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:24 -0700 Subject: [PATCH 20/86] Answer typeof for Web constructor globals and their static methods ky probes `typeof globalThis.AbortSignal === 'function' && typeof globalThis.AbortSignal.any === 'function'`. In JavaScript sources these values are identity tokens, so typeof answered "string", and the static method read refused. Node 24's Web-platform constructor globals and the static methods of AbortSignal, Response and URL now answer "function", also inside typeof comparisons, where a fallback-typed receiver narrows to never after the first guard. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 96 +++++++++++++++++++ .../6210-typeof-global-static-members.js.json | 6 ++ .../6210-typeof-global-static-members.js | 11 +++ 3 files changed, 113 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json create mode 100644 tests/corpus/6210-typeof-global-static-members.js diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8c8b1c09b..3b068f1ba 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -686,6 +686,31 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { } } } + // `typeof globalThis.AbortSignal` / `typeof Headers`: Node 24's Web + // platform constructor globals are functions. JavaScript sources + // represent such a value as an identity token, and the fallback + // declarations type some (AbortSignal) without a construct signature, + // so neither the operand nor the type can answer; the read has no + // effects. + if (constructorGlobalTypeof(lowerer, tested) !== null) + return { kind: "strLit", value: "function", type: STRING, loc }; + // `typeof globalThis.AbortSignal.any` (ky's feature probe): a static + // member of a standard-library global, declared callable, whose value + // has no representation. The read has no effects and Node's answer + // for every such function is "function"; members that lower as values + // keep their own answer. + if (ts.isPropertyAccessExpression(tested) && !tested.questionDotToken) { + const owner = stdlibGlobalNameOf(lowerer, tested.expression); + if (owner !== null && lowerer.isStdlibMember(tested)) { + const t = lowerer.typeOf(tested); + if ( + (lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0) && + tryLowerExpression(lowerer, tested) === null + ) + return { kind: "strLit", value: "function", type: STRING, loc }; + } + } if (ts.isIdentifier(tested)) { const sym = lowerer.checker.getSymbolAtLocation(tested); if (!sym) { @@ -8744,6 +8769,76 @@ export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrEx return lowerRecordPropertyKey(lowerer, key, node); } +/** `typeof` of a Node 24 Web-platform constructor global or one of its + * static methods: "function", decided by name. The read has no effects. + * Static methods answer by name because after a `typeof + * globalThis.AbortSignal === "function" &&` guard the checker narrows a + * fallback-typed receiver to never, hiding the member's declaration. */ +function constructorGlobalTypeof(lowerer: Lowerer, tested: ts.Expression): string | null { + while (ts.isParenthesizedExpression(tested)) tested = tested.expression; + const globalName = stdlibGlobalNameOf(lowerer, tested); + if (globalName !== null && NODE_CONSTRUCTOR_GLOBALS.has(globalName)) return "function"; + if (!ts.isPropertyAccessExpression(tested) || tested.questionDotToken) return null; + const owner = stdlibGlobalNameOf(lowerer, tested.expression); + return owner !== null && + Object.hasOwn(NODE_CONSTRUCTOR_STATICS, owner) && + NODE_CONSTRUCTOR_STATICS[owner]!.includes(tested.name.text) + ? "function" + : null; +} + +/** `typeof globalThis.AbortSignal.any === "function"`: the comparison + * folds with constructorGlobalTypeof's answer. */ +function lowerConstructorGlobalTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a) || !ts.isStringLiteral(b)) continue; + const answer = constructorGlobalTypeof(lowerer, a.expression); + if (answer !== null) + return { kind: "boolLit", value: (answer === b.text) !== negated, type: BOOL, loc }; + } + return null; +} + +/** Static methods of Node 24's Web-platform constructor globals. */ +const NODE_CONSTRUCTOR_STATICS: Readonly> = { + AbortSignal: ["abort", "any", "timeout"], + Response: ["error", "json", "redirect"], + URL: ["canParse", "createObjectURL", "parse", "revokeObjectURL"], +}; + +/** Node 24's Web-platform constructor globals: `typeof` answers + * "function" for each. */ +const NODE_CONSTRUCTOR_GLOBALS: ReadonlySet = new Set([ + "AbortController", + "AbortSignal", + "Blob", + "BroadcastChannel", + "CustomEvent", + "DOMException", + "Event", + "EventTarget", + "File", + "FormData", + "Headers", + "MessageChannel", + "MessageEvent", + "MessagePort", + "ReadableStream", + "Request", + "Response", + "TextDecoder", + "TextEncoder", + "TransformStream", + "URL", + "URLSearchParams", + "WritableStream", +]); + export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { if (e.type.kind === "string") return e; if (e.type.kind === "url") @@ -10262,6 +10357,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr op === ts.SyntaxKind.ExclamationEqualsEqualsToken ) { const test = + lowerConstructorGlobalTypeofTest(lowerer, expr, loc) ?? lowerErrorCodeTypeofTest(lowerer, expr, loc) ?? lowerer.lowerCaughtTypeofTest(expr, loc) ?? lowerDynTypeofTest(lowerer, expr, loc) ?? diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json new file mode 100644 index 000000000..33ccb2715 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6210-typeof-global-static-members.js" + ], + "diags": [] +} diff --git a/tests/corpus/6210-typeof-global-static-members.js b/tests/corpus/6210-typeof-global-static-members.js new file mode 100644 index 000000000..88c3c40a1 --- /dev/null +++ b/tests/corpus/6210-typeof-global-static-members.js @@ -0,0 +1,11 @@ +// ky's feature probes: typeof Node's Web-platform constructor globals and +// their static methods answers "function" without materializing the value. +export const supportsAbortController = typeof globalThis.AbortController === "function"; +export const supportsAbortSignal = + typeof globalThis.AbortSignal === "function" && typeof globalThis.AbortSignal.any === "function"; +console.log(supportsAbortController, supportsAbortSignal); +console.log(typeof AbortSignal.timeout, typeof AbortSignal.abort, typeof globalThis.AbortSignal.any); +console.log(typeof Response.json, typeof Response.error, typeof URL.canParse, typeof Promise.withResolvers); +console.log(typeof AbortSignal, typeof globalThis.URL, typeof Headers, typeof globalThis.TextEncoder, typeof URLSearchParams); +const signal = AbortSignal.abort("stop"); +console.log(signal.aborted, signal.reason); From ec4a6930914843e71a93a9b9e6b18999c700ed2d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:27 -0700 Subject: [PATCH 21/86] Map crypto.getRandomValues and fs.constants in the compatibility ledger crypto.getRandomValues (crypto and webcrypto chapters) is supported with corpus 6204/6205; fs.constants is partial (the fs group, not the other platforms' sets) with corpus 6207/6208. Regenerated with pnpm manifest and pnpm node-compat. Co-Authored-By: Claude Opus 5.5 --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 36 ++++---- .../generated/node-v24-backlog.json | 90 +++++-------------- .../generated/node-v24-internal.json | 50 ++++++++--- internal/compatibility/static-support.json | 3 + packages/compiler/surface-manifest.json | 13 +++ 6 files changed, 94 insertions(+), 100 deletions(-) diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index a64169933..4397b4b72 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "a610bdd1ae56e1c4cd38", + "artifactVersion": "02a00aa34299b18710fc", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index d4b6839e7..d922674e5 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -208,10 +208,10 @@ "entries": 152, "apiEntries": 152, "static": { - "supported": 5, + "supported": 6, "partial": 39, "refused": 21, - "not-implemented": 87, + "not-implemented": 86, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -314,9 +314,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 198, + "partial": 200, "refused": 0, - "not-implemented": 75, + "not-implemented": 73, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -853,10 +853,10 @@ "entries": 156, "apiEntries": 156, "static": { - "supported": 1, + "supported": 2, "partial": 10, "refused": 0, - "not-implemented": 145, + "not-implemented": 144, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -12325,9 +12325,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -30288,9 +30288,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -35256,9 +35256,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -80183,9 +80183,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index f36c865cf..5d75728c1 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -3,19 +3,19 @@ "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", "summary": { - "taskCount": 3456, - "tierItemCount": 3456, + "taskCount": 3454, + "tierItemCount": 3454, "tiers": { "static": { - "audit-partial": 1155, - "verify-gap": 2174, + "audit-partial": 1157, + "verify-gap": 2170, "replace-refusal": 40, "classify": 2, "implement": 85 } }, "priorities": { - "high": 2289, + "high": 2287, "normal": 694, "low": 473 } @@ -7348,31 +7348,6 @@ } } }, - { - "id": "crypto:2ab8394e29a7", - "chapter": "crypto", - "label": "crypto.getRandomValues(typedArray)", - "signature": "`crypto.getRandomValues(typedArray)`", - "apiSymbol": "crypto.getRandomValues", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/crypto.html#cryptogetrandomvaluestypedarray", - "nodeStability": { - "index": "2", - "level": 2, - "text": "Stable", - "inherited": true - }, - "priority": "high", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:crypto", - "tests": [] - } - } - }, { "id": "crypto:21f22c71fb61", "chapter": "crypto", @@ -27036,12 +27011,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] } } }, @@ -32733,12 +32711,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] } } }, @@ -80964,31 +80945,6 @@ } } }, - { - "id": "webcrypto:dcc24f369f43", - "chapter": "webcrypto", - "label": "crypto.getRandomValues(typedArray)", - "signature": "`crypto.getRandomValues(typedArray)`", - "apiSymbol": "crypto.getRandomValues", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/webcrypto.html#cryptogetrandomvaluestypedarray", - "nodeStability": { - "index": "2", - "level": 2, - "text": "Stable", - "inherited": true - }, - "priority": "high", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:webcrypto", - "tests": [] - } - } - }, { "id": "webcrypto:8c570ec75666", "chapter": "webcrypto", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 514282a0a..f1f3a0f30 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -190,10 +190,10 @@ "entries": 179, "apiEntries": 152, "static": { - "supported": 5, + "supported": 6, "partial": 39, "refused": 21, - "not-implemented": 87, + "not-implemented": 86, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -326,9 +326,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 198, + "partial": 200, "refused": 0, - "not-implemented": 75, + "not-implemented": 73, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -910,10 +910,10 @@ "entries": 176, "apiEntries": 156, "static": { - "supported": 1, + "supported": 2, "partial": 10, "refused": 0, - "not-implemented": 145, + "not-implemented": 144, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -19861,6 +19861,8 @@ "tests/corpus/5760-crypto-hash-stream.ts", "tests/corpus/5761-crypto-hash-stream-states.ts", "tests/corpus/5762-crypto-hash-stream.cjs", + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/node24-crypto-kdf.ts", @@ -21013,8 +21015,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "supported", + "evidence": "compiler-dedicated:crypto.getRandomValues", + "tests": [ + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js" + ] }, "anchorSource": "exact" }, @@ -44097,6 +44103,8 @@ "tests/corpus/5568-fs-cp-errors.js", "tests/corpus/5569-fs-cp-symlinks.ts", "tests/corpus/5570-fs-cp-async.ts", + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs", "tests/corpus/fs-native-realpath.ts", "tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", @@ -45884,8 +45892,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] }, "anchorSource": "ancestor" }, @@ -51756,8 +51768,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] }, "anchorSource": "ancestor" }, @@ -106766,6 +106782,8 @@ "tests/corpus/2890-crypto-static-utility.ts", "tests/corpus/5442-crypto-random-values.ts", "tests/corpus/5443-crypto-random-arguments.js", + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/node24-crypto-kdf.ts", @@ -110250,8 +110268,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:webcrypto" + "status": "supported", + "evidence": "compiler-dedicated:crypto.getRandomValues", + "tests": [ + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 45a946925..66590fd96 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -111,6 +111,8 @@ "node-builtin.os.uptime": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.version": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.constants": ["tests/corpus/5141-os-constants.ts"], + "node-builtin.fs.constants": ["tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs"], + "node-builtin.crypto.getRandomValues": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"], "node-builtin.url.parse": ["tests/corpus/5220-url-parse-warnings.ts", "tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", "tests/corpus/5223-url-parse-query.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"], "node-builtin.url.format": ["tests/corpus/5224-url-format-objects.ts", "tests/corpus/5225-url-format-whatwg.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"], "node-builtin.url.resolve": ["tests/corpus/5226-url-resolve.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"], @@ -389,6 +391,7 @@ { "symbols": ["crypto.createHash", "Hash", "hash.update", "hash.digest", "hash.copy", "crypto.createHmac", "Hmac", "hmac.update", "hmac.digest", "crypto.hash", "crypto.pbkdf2", "crypto.pbkdf2Sync"], "status": "partial", "evidence": ["tests/corpus/5760-crypto-hash-stream.ts", "tests/corpus/5761-crypto-hash-stream-states.ts", "tests/corpus/5762-crypto-hash-stream.cjs", "tests/corpus/2890-crypto-static-utility.ts", "tests/corpus/node24-crypto-sha2.ts", "tests/corpus/5435-crypto-hash-algorithms.ts", "tests/corpus/5436-crypto-hash-names.js", "tests/corpus/5437-crypto-hmac-algorithms.ts", "tests/corpus/5438-crypto-kdf-digests.ts", "tests/corpus/5440-crypto-hash-encodings.js", "tests/corpus/5441-crypto-hash-output-length.ts", "tests/harness/npm-static.test.ts"] }, { "symbols": ["crypto.randomFill", "crypto.randomFillSync", "crypto.randomUUID", "crypto.randomUUIDv7"], "status": "supported", "evidence": ["tests/corpus/5442-crypto-random-values.ts", "tests/corpus/5443-crypto-random-arguments.js", "tests/corpus/1358-crypto-random.ts"] }, { "symbols": ["crypto.createSecretKey", "crypto.generateKey", "crypto.generateKeySync", "KeyObject", "keyObject.export", "keyObject.equals", "keyObject.symmetricKeySize", "keyObject.type"], "status": "partial", "evidence": ["tests/corpus/5445-crypto-secret-key-objects.ts", "tests/corpus/5446-crypto-secret-key-errors.js"] }, + { "symbols": ["crypto.getRandomValues"], "status": "supported", "evidence": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"] }, { "symbols": ["crypto.timingSafeEqual"], "status": "supported", "evidence": ["tests/corpus/5444-crypto-timing-safe-equal.js", "tests/corpus/2890-crypto-static-utility.ts"] }, { "symbols": ["crypto.randomInt"], "status": "partial", "evidence": ["tests/corpus/5442-crypto-random-values.ts", "tests/corpus/2890-crypto-static-utility.ts"] }, { "symbols": ["crypto.hkdf", "crypto.hkdfSync", "crypto.scrypt", "crypto.scryptSync"], "status": "partial", "evidence": ["tests/corpus/node24-crypto-kdf.ts", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/5438-crypto-kdf-digests.ts"] }, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index f5313f12d..8d13d02ac 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -611,6 +611,12 @@ "code": "SC2020", "note": "asymmetric-key operations need a public-key stack (bignum, RSA/EC/EdDSA math) and public and private KeyObjects — neither exists in the static runtime, so no faithful lowering can be small; the lowered crypto surface is hashing, randomness, secret KeyObjects, and the introspection statics" }, + { + "id": "node-builtin.crypto.getRandomValues", + "kind": "node-builtin", + "name": "crypto.getRandomValues", + "status": "static" + }, { "id": "node-builtin.crypto.hash", "kind": "node-builtin", @@ -861,6 +867,13 @@ "name": "fs.closeSync", "status": "static" }, + { + "id": "node-builtin.fs.constants", + "kind": "node-builtin", + "name": "fs.constants", + "status": "static", + "note": "node_constants.cc's fs group under the target libc's macros; members a platform lacks read as undefined" + }, { "id": "node-builtin.fs.copyFileSync", "kind": "node-builtin", From 8ec0d9e0b9c573d85e7b8fbde3c4f5ea1258c0e1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:57:42 -0700 Subject: [PATCH 22/86] 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 0ee1205bc469c9d01eff9024ca5d3701c118cbd7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 16:05:10 -0700 Subject: [PATCH 23/86] Keep fs.constants on its own table beside node:constants Calling the node:constants fs fill from a second site stops that static from inlining into scr_node_constants and changes every binary linking node:constants; fs.constants keeps its own table (now with the UV_FS_O_* Windows-only flags). Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_os.c | 167 ++++++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index da51bc25f..500283995 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -1527,12 +1527,164 @@ ScrDyn *scr_node_constants(const ScrDyn *crypto_table) { return scr_dyn_retain(o); } -/* fs.constants as a value: the fs group node:constants merges (the - * scr_fs_constants fill above, node_constants.cc's order under the target - * libc's guards) on its own cached null-prototype object with read-only - * members. Defined after scr_node_constants so the shared static fill - * keeps its place in binaries that link node:constants. Nothing on an - * existing path calls this. */ +/* fs.constants as a value: node_constants.cc's fs group (the set + * node:constants merges above), in its order and under its #ifdef guards, + * on its own cached null-prototype object with read-only members. It keeps + * its own table instead of calling the scr_fs_constants fill: a second + * caller would stop that static from inlining into scr_node_constants and + * change every binary that links node:constants. The names live inside + * the table entries, so the table is one data object section GC drops. + * Nothing on an existing path calls this. */ +typedef struct { + char name[32]; + double value; +} ScrFsConstant; +#define SCR_FS_C(n) {#n, (double)(n)} +static const ScrFsConstant scr_fs_constant_table[] = { + {"UV_FS_SYMLINK_DIR", 1}, + {"UV_FS_SYMLINK_JUNCTION", 2}, +#ifdef O_RDONLY + SCR_FS_C(O_RDONLY), +#endif +#ifdef O_WRONLY + SCR_FS_C(O_WRONLY), +#endif +#ifdef O_RDWR + SCR_FS_C(O_RDWR), +#endif + {"UV_DIRENT_UNKNOWN", 0}, + {"UV_DIRENT_FILE", 1}, + {"UV_DIRENT_DIR", 2}, + {"UV_DIRENT_LINK", 3}, + {"UV_DIRENT_FIFO", 4}, + {"UV_DIRENT_SOCKET", 5}, + {"UV_DIRENT_CHAR", 6}, + {"UV_DIRENT_BLOCK", 7}, +#ifdef S_IFMT + SCR_FS_C(S_IFMT), +#endif +#ifdef S_IFREG + SCR_FS_C(S_IFREG), +#endif +#ifdef S_IFDIR + SCR_FS_C(S_IFDIR), +#endif +#ifdef S_IFCHR + SCR_FS_C(S_IFCHR), +#endif +#ifdef S_IFBLK + SCR_FS_C(S_IFBLK), +#endif +#ifdef S_IFIFO + SCR_FS_C(S_IFIFO), +#endif +#ifdef S_IFLNK + SCR_FS_C(S_IFLNK), +#endif +#ifdef S_IFSOCK + SCR_FS_C(S_IFSOCK), +#endif +#ifdef O_CREAT + SCR_FS_C(O_CREAT), +#endif +#ifdef O_EXCL + SCR_FS_C(O_EXCL), +#endif + {"UV_FS_O_FILEMAP", 0}, + {"UV_FS_O_TEMPORARY", 0}, + {"UV_FS_O_SHORT_LIVED", 0}, + {"UV_FS_O_SEQUENTIAL", 0}, + {"UV_FS_O_RANDOM", 0}, +#ifdef O_NOCTTY + SCR_FS_C(O_NOCTTY), +#endif +#ifdef O_TRUNC + SCR_FS_C(O_TRUNC), +#endif +#ifdef O_APPEND + SCR_FS_C(O_APPEND), +#endif +#ifdef O_DIRECTORY + SCR_FS_C(O_DIRECTORY), +#endif +#ifdef O_NOATIME + SCR_FS_C(O_NOATIME), +#endif +#ifdef O_NOFOLLOW + SCR_FS_C(O_NOFOLLOW), +#endif +#ifdef O_SYNC + SCR_FS_C(O_SYNC), +#endif +#ifdef O_DSYNC + SCR_FS_C(O_DSYNC), +#endif +#ifdef O_SYMLINK + SCR_FS_C(O_SYMLINK), +#endif +#ifdef O_DIRECT + SCR_FS_C(O_DIRECT), +#endif +#ifdef O_NONBLOCK + SCR_FS_C(O_NONBLOCK), +#endif +#ifdef S_IRWXU + SCR_FS_C(S_IRWXU), +#endif +#ifdef S_IRUSR + SCR_FS_C(S_IRUSR), +#endif +#ifdef S_IWUSR + SCR_FS_C(S_IWUSR), +#endif +#ifdef S_IXUSR + SCR_FS_C(S_IXUSR), +#endif +#ifdef S_IRWXG + SCR_FS_C(S_IRWXG), +#endif +#ifdef S_IRGRP + SCR_FS_C(S_IRGRP), +#endif +#ifdef S_IWGRP + SCR_FS_C(S_IWGRP), +#endif +#ifdef S_IXGRP + SCR_FS_C(S_IXGRP), +#endif +#ifdef S_IRWXO + SCR_FS_C(S_IRWXO), +#endif +#ifdef S_IROTH + SCR_FS_C(S_IROTH), +#endif +#ifdef S_IWOTH + SCR_FS_C(S_IWOTH), +#endif +#ifdef S_IXOTH + SCR_FS_C(S_IXOTH), +#endif +#ifdef F_OK + SCR_FS_C(F_OK), +#endif +#ifdef R_OK + SCR_FS_C(R_OK), +#endif +#ifdef W_OK + SCR_FS_C(W_OK), +#endif +#ifdef X_OK + SCR_FS_C(X_OK), +#endif + {"UV_FS_COPYFILE_EXCL", 1}, + {"COPYFILE_EXCL", 1}, + {"UV_FS_COPYFILE_FICLONE", 2}, + {"COPYFILE_FICLONE", 2}, + {"UV_FS_COPYFILE_FICLONE_FORCE", 4}, + {"COPYFILE_FICLONE_FORCE", 4}, +}; +#undef SCR_FS_C + static SCR_TL ScrDyn *scr_fs_constants_value; static void scr_fs_constants_clear(void) { ScrDyn *value = scr_fs_constants_value; @@ -1543,7 +1695,8 @@ static void scr_fs_constants_clear(void) { ScrDyn *scr_fs_constants_object(void) { if (scr_fs_constants_value) return scr_dyn_retain(scr_fs_constants_value); ScrDyn *o = scr_dyn_new_obj_null_proto(); - scr_fs_constants(o); + for (size_t i = 0; i < sizeof scr_fs_constant_table / sizeof scr_fs_constant_table[0]; i++) + scr_os_constant(o, scr_fs_constant_table[i].name, scr_fs_constant_table[i].value); scr_fs_constants_value = o; scr_atexit(scr_fs_constants_clear); return scr_dyn_retain(o); From a1576d7c5f755be8847d3ddb29f851bfeefe16b5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:10:05 -0700 Subject: [PATCH 24/86] 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 25/86] =?UTF-8?q?Answer=20NaN=20for=20**=20with=20a=20base?= =?UTF-8?q?=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 26/86] 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 27/86] 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 28/86] 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 29/86] 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 3ffcdfb08fb126cd918c5621886575789495dbb8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 19:34:12 -0700 Subject: [PATCH 30/86] Refuse a default-locale toLocaleString in the coverage tier fixtures This branch lowers Number.prototype.toPrecision statically, so the tier-demotions and dynamic-mix coverage fixtures lost the operation they used as "no static lowering". Use n.toLocaleString() instead: the host's default locale is something a compiled binary cannot carry, so it stays refused (SC2020) and the fixtures keep the same blocker and demotion request on the same lines. Co-Authored-By: Claude Opus 5.5 --- tests/coverage-fixtures/dynamic-mix.ts | 4 ++-- tests/coverage-fixtures/tier-demotions.ts | 4 ++-- tests/harness/__snapshots__/coverage-dynamic-mix.txt | 4 ++-- tests/harness/__snapshots__/coverage-explain-dynamic.txt | 2 +- tests/harness/__snapshots__/coverage-tier-demotions.txt | 4 ++-- tests/harness/coverage.test.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/coverage-fixtures/dynamic-mix.ts b/tests/coverage-fixtures/dynamic-mix.ts index 0c9779a64..d70141b91 100644 --- a/tests/coverage-fixtures/dynamic-mix.ts +++ b/tests/coverage-fixtures/dynamic-mix.ts @@ -1,9 +1,9 @@ // Native checked values and loose equality share this program with -// island-backed APIs (SC2012) with native compilation refusals. +// native compilation refusals (a default-locale toLocaleString, SC2020). const v: any = 21; const doubled = v * 2; const root = Math.cbrt(81); -const up = (19.99).toPrecision(3); +const up = (19.99).toLocaleString(); const parsed = Number.parseFloat("1.5"); // a string argument compiles statically, like the global const unknownScore: unknown = 81; const flags = unknownScore == 81; diff --git a/tests/coverage-fixtures/tier-demotions.ts b/tests/coverage-fixtures/tier-demotions.ts index 52f2058f1..3dbd0c3fe 100644 --- a/tests/coverage-fixtures/tier-demotions.ts +++ b/tests/coverage-fixtures/tier-demotions.ts @@ -17,9 +17,9 @@ function readName(text: string): string { return String(data.name); } -// An operation without a static lowering, on a parameter. +// An operation without a static lowering (the host's default locale), on a parameter. function precise(n: number): string { - return n.toPrecision(3); + return n.toLocaleString(); } console.log(readName('{"name":"a"}'), precise(1.5), new Point(1, 2).x); diff --git a/tests/harness/__snapshots__/coverage-dynamic-mix.txt b/tests/harness/__snapshots__/coverage-dynamic-mix.txt index 2ad84f0af..0bff0f03c 100644 --- a/tests/harness/__snapshots__/coverage-dynamic-mix.txt +++ b/tests/harness/__snapshots__/coverage-dynamic-mix.txt @@ -5,6 +5,6 @@ scriptc coverage tests/coverage-fixtures/dynamic-mix.ts in dynamic tier 2 (operate on values typed only at runtime; --explain-dynamic lists them) blockers: - ×1 SC2012 '.toPrecision()' on numbers cannot compile statically + ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet at tests/coverage-fixtures/dynamic-mix.ts:6:12 - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file + hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-explain-dynamic.txt b/tests/harness/__snapshots__/coverage-explain-dynamic.txt index 1c86a808c..f168988e6 100644 --- a/tests/harness/__snapshots__/coverage-explain-dynamic.txt +++ b/tests/harness/__snapshots__/coverage-explain-dynamic.txt @@ -12,5 +12,5 @@ function tests/coverage-fixtures/tier-demotions.ts:1:1 (1 site inside) function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 site inside) binding n in precise tests/coverage-fixtures/tier-demotions.ts:21:18 (1 request) - <- SC2012 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: '.toPrecision()' on numbers cannot compile statically + <- SC2020 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet -> function precise turns dynamic \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-tier-demotions.txt b/tests/harness/__snapshots__/coverage-tier-demotions.txt index 7b52f9d63..aaf1f4e3b 100644 --- a/tests/harness/__snapshots__/coverage-tier-demotions.txt +++ b/tests/harness/__snapshots__/coverage-tier-demotions.txt @@ -8,6 +8,6 @@ scriptc coverage tests/coverage-fixtures/tier-demotions.ts ×1 SC1090 assignment to non-variables at tests/coverage-fixtures/tier-demotions.ts:10:1 hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) - ×1 SC2012 '.toPrecision()' on numbers cannot compile statically + ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet at tests/coverage-fixtures/tier-demotions.ts:22:10 - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file + hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index f1347bf23..8cf5d6719 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -295,7 +295,7 @@ test("--explain-dynamic attributes each refusal to the unit the dynamic tier wou requests.map((r) => [r.code, r.seed, r.scope, r.unit.kind, r.unit.name, r.enclosing.name]), ).toEqual([ ["SC1090", "reflection", "reached", "class", "Point", ""], - ["SC2012", "operation", "reached", "binding", "n", "precise"], + ["SC2020", "operation", "reached", "binding", "n", "precise"], ]); expect(tiers.units.map((u) => [u.kind, u.name, u.requests, u.sites])).toEqual([ ["class", "Point", 1, 0], From 9f2e9b00c90b879eef27b4bc66d79c9464decdb9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 00:11:31 -0500 Subject: [PATCH 31/86] 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/
/