From bb0e0a79d5bd7a1bceb8d44e61135232f1b188cc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 08:02:00 -0700 Subject: [PATCH 01/44] Compare generic declarations by identity across pinned signatures An unpinned generic declaration compared with `===`/`!==` takes its signature from the other operand (`held === ident`), which clears the pi `agent-session.ts:590` shape. That pin alone would answer `false` where JavaScript answers `true` when the program also holds the declaration at another signature, so a whole-program check refuses the comparison instead. It covers the direct form and values compared through `unknown` (the g5 silent divergence: two instances of one declaration compared unequal), resolving dynamic operands' provenance only when some declaration is held at more than one signature. - generic-functions: `identityComparisonPin` and `checkGenericIdentityPins` (runs once at the end of lowering, when every value instance is known); `GenericFnInfo.valueInstances` records the instances value references produced. - Corpus: `generic-identity-pin.ts`, `generic-identity-unknown.ts`. - Diagnostics: `generic-identity-multiple-pins.ts`, `generic-identity-unknown.ts`. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/expressions/binary.ts | 14 ++ .../frontend/lowering/generic-functions.ts | 172 ++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 21 +++ tests/corpus/generic-identity-pin.ts | 17 ++ tests/corpus/generic-identity-unknown.ts | 11 ++ .../generic-identity-multiple-pins.ts | 11 ++ tests/diagnostics/generic-identity-unknown.ts | 12 ++ .../generic-identity-multiple-pins.ts.txt | 6 + .../generic-identity-unknown.ts.txt | 6 + .../corpus-costs/generic-identity-pin.ts.json | 1 + .../generic-identity-unknown.ts.json | 1 + 11 files changed, 272 insertions(+) create mode 100644 tests/corpus/generic-identity-pin.ts create mode 100644 tests/corpus/generic-identity-unknown.ts create mode 100644 tests/diagnostics/generic-identity-multiple-pins.ts create mode 100644 tests/diagnostics/generic-identity-unknown.ts create mode 100644 tests/harness/__snapshots__/generic-identity-multiple-pins.ts.txt create mode 100644 tests/harness/__snapshots__/generic-identity-unknown.ts.txt create mode 100644 tests/harness/corpus-costs/generic-identity-pin.ts.json create mode 100644 tests/harness/corpus-costs/generic-identity-unknown.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index e20604f92..440ed7b05 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -58,6 +58,7 @@ import { tryLowerExpression } from "./try-lower-expression.js"; import { fenceNodeModuleMutation, lowerRequireCacheHas } from "../lower-node-module.js"; import { errorToStringCall } from "../error-methods.js"; import { classToStringConversion, classUnionToString } from "../class-to-primitive.js"; +import { noteDynamicIdentityComparison } from "../generic-functions.js"; import { isPerfEntryType, perfEntryTag } from "../builtins/performance.js"; import { isX509Type, x509ToString } from "../builtins/x509.js"; import { guardNullProto, nullProtoRecordShape } from "../null-prototype.js"; @@ -1668,6 +1669,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr ? { liveRef: true as const } : {}), }; + if (reference.kind === "func") + noteDynamicIdentityComparison(lowerer, expr, expr.left, expr.right); return { kind: "dynScalarEq", left: scalarSide === left ? boxed : left, @@ -1720,6 +1723,12 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr // reference kinds by identity (scr_dyn_strict_eq). scalarSide.type.kind === "dyn") ) { + // A generic declaration read through `unknown` compares as the + // function object it names, which is one object in JavaScript but + // one closed instance per signature here: the whole-program check + // refuses it when the program holds the declaration at more than + // one signature. + noteDynamicIdentityComparison(lowerer, expr, expr.left, expr.right); return { kind: "dynScalarEq", left, @@ -2679,6 +2688,11 @@ export function lowerStrictEquality( }; } if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) { + // A generic declaration read through `unknown` compares as the function + // object it names, which is one object in JavaScript but one closed + // instance per signature here: the whole-program check refuses it when + // the program holds the declaration at more than one signature. + noteDynamicIdentityComparison(lowerer, node, leftNode, rightNode); return { kind: "dynScalarEq", left: lowerer.coerceToExpected(idLeft, DYN), diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 046b34008..98d85be69 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -22,6 +22,7 @@ import { typeEquals, } from "../../ir/ir.js"; import { helperLoc, nodeThrowExpr } from "../../ir/build.js"; +import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; import { isJsSourceFile, locOf } from "../program.js"; import { typeKey } from "../type-mapper.js"; import { functionCanReturnUndefined } from "../function-completion.js"; @@ -129,6 +130,11 @@ export interface GenericFnInfo { /** Parameter index → symbol for the function parameters whose `.name` * the body reads (static-function-names.ts); computed on first use. */ nameReadParams?: Map; + /** Names of the instances value references produced (pinnedGenericClosure, + * erasedGenericInstance, the implicit-any default): more than one means the + * program holds this declaration as several function objects, while + * JavaScript has one. */ + valueInstances?: Set; } export interface GenericInstance { @@ -1349,6 +1355,7 @@ export function lowerGenericFnValue( // closure per function, so `f === f` holds like any declaration). if (info.implicitParams) { const inst = implicitDefaultInstance(lowerer, ref, info); + (info.valueInstances ??= new Set()).add(inst.name); const funcType: IrType = funcTypeFromParamShapes(inst.params, inst.returnType); lowerer.requireExactArityValue(ref, ref, inst.params, funcType); lowerer.noteEdge(inst.name); @@ -1398,6 +1405,10 @@ export function lowerGenericFnValue( if (callable.length === 1) target = callable[0]![0]!; } } + if (!target) { + target = identityComparisonPin(lowerer, ref); + if (target) lowerer.genericIdentityPins.push({ info, node: ref }); + } if (!target) fenceUnpinned(); return guardEarlyBindingValue( lowerer, @@ -1407,6 +1418,41 @@ export function lowerGenericFnValue( ); } +/** The pinning signature of a generic declaration compared by identity + * (`held === ident`, `held !== ident`, and the loose forms): the other + * operand's one concrete call signature. A held value of that type that is + * this declaration is its instance at that signature, whose interned + * closure is the one this reference names, so the comparison answers as + * in JavaScript, provided the program holds the declaration at that one + * signature only (checkGenericIdentityPins). Null when the other operand + * has no single concrete call signature (another generic value, a union of + * callables, a non-function). */ +function identityComparisonPin(lowerer: Lowerer, ref: ts.Expression): ts.Signature | null { + let operand: ts.Node = ref; + if ( + ref.parent !== undefined && + ts.isPropertyAccessExpression(ref.parent) && + ref.parent.name === ref + ) + operand = ref.parent; + while (operand.parent !== undefined && ts.isParenthesizedExpression(operand.parent)) + operand = operand.parent; + const bin = operand.parent; + if (bin === undefined || !ts.isBinaryExpression(bin)) return null; + const op = bin.operatorToken.kind; + if ( + op !== ts.SyntaxKind.EqualsEqualsEqualsToken && + op !== ts.SyntaxKind.ExclamationEqualsEqualsToken && + op !== ts.SyntaxKind.EqualsEqualsToken && + op !== ts.SyntaxKind.ExclamationEqualsToken + ) + return null; + const other = bin.left === operand ? bin.right : bin.left; + const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(other)); + if (sigs.length !== 1 || sigs[0]!.getTypeParameters()?.length) return null; + return sigs[0]!; +} + /** The value a generic binding declared inside a function stores when a * nested function read it before the declaration lowered (`const read = * () => id; const id: (x: number) => number = (x: T): T => x`): such @@ -1513,6 +1559,7 @@ function pinnedGenericClosure( // nothing: mapType answers null for an unsubstituted parameter. if (info.typeParams.some((tp) => !bindings.get(tp))) fenceUnpinned(); const inst = genericValueInstance(lowerer, ref, info, bindings); + (info.valueInstances ??= new Set()).add(inst.name); // The value's type is the completed ABI signature. Typed rest keeps its // packed-array slot; dynRest stays hidden behind the rest marker. const funcType: IrType = funcTypeFromParamShapes(inst.params, inst.returnType); @@ -1562,12 +1609,137 @@ export function erasedGenericInstance( new Map(info.typeParams.map((tp) => [tp, DYN])), ); instance.erased = true; + (info.valueInstances ??= new Set()).add(instance.name); return instance; } finally { lowerer.erasingDepth--; } } +/** After every body lowered: an identity comparison of a generic + * declaration is exact only when the program holds the declaration as one + * function object. Otherwise a held value may be another instance's + * closure, where JavaScript has one function, so the comparison is + * refused instead of answering false. */ +export function checkGenericIdentityPins(lowerer: Lowerer): void { + if (lowerer.genericIdentityCheckDone) return; + lowerer.genericIdentityCheckDone = true; + const seen = new Map>(); + const report = (info: GenericFnInfo, node: ts.Node, dynamic: boolean): void => { + let infos = seen.get(node); + if (infos === undefined) seen.set(node, (infos = new Set())); + if (infos.has(info)) return; + infos.add(info); + const held = info.valueInstances?.size ?? 0; + if (held <= 1) return; + const through = dynamic ? " through a dynamic value" : ""; + lowerer.pushDiag( + unsupportedDiag( + "SC1090", + locOf(node), + `comparing the generic function '${info.baseName}' by identity${through} while the program holds it at ${held} different signatures (each is a separate compiled function object; JavaScript has one) — compare against a value pinned once, or hold '${info.baseName}' at a single signature`, + ), + ); + }; + for (const { info, node } of lowerer.genericIdentityPins) report(info, node, false); + // Dynamic comparisons resolve their provenance only when some declaration + // is held at more than one signature: the symbol lookups are the + // expensive part, and every other program answers them all at one + // instance. Recorded at lowering, resolved here so the pins' instance + // sets are complete. + let ambiguous = false; + for (const info of lowerer.genericFnsBySymbol.values()) { + if ((info.valueInstances?.size ?? 0) > 1) { + ambiguous = true; + break; + } + } + if (!ambiguous) return; + for (const { node, left, right } of lowerer.dynIdentityComparisons) { + for (const operand of [left, right]) { + const info = genericDeclarationBehindValue(lowerer, operand); + if (info) report(info, node, true); + } + } +} + +/** The generic declaration whose value instance an expression names, when + * the expression is a binding initialized (directly, or through casts, + * aliasing bindings and property initializers) from that declaration. Used + * where a comparison reads the value through `unknown`/`any`, so the + * declaration is not syntactically at the compared site. */ +export function genericDeclarationBehindValue( + lowerer: Lowerer, + node: ts.Node, +): GenericFnInfo | null { + let current: ts.Node | undefined = node; + const seen = new Set(); + while (current !== undefined && !seen.has(current)) { + seen.add(current); + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + continue; + } + if (!ts.isIdentifier(current) && !ts.isPropertyAccessExpression(current)) return null; + const symbol = lowerer.checker.getSymbolAtLocation( + ts.isPropertyAccessExpression(current) ? current.name : current, + ); + if (symbol === undefined) return null; + const info = lowerer.genericFnsBySymbol.get(symbol); + if (info !== undefined) return info; + const decl = symbol.valueDeclaration ?? symbol.declarations?.[0]; + if (decl === undefined) return null; + if (!ts.isVariableDeclaration(decl) && !ts.isPropertyDeclaration(decl) && !ts.isParameter(decl)) + return null; + current = decl.initializer; + } + return null; +} + +/** An identity comparison at the dynamic boundary (`unknown`/`any` + * operands): when either operand reads a generic declaration's value + * instance, the comparison is exact only while the program holds that + * declaration at one signature (checkGenericIdentityPins). */ +export function noteDynamicIdentityComparison( + lowerer: Lowerer, + node: ts.Node, + left: ts.Node, + right: ts.Node, +): void { + // The only work done while lowering. No instance count can gate this: + // a value reference's instance is interned when its binding settles, + // which can be later than the comparison that reads it, so the notes + // are resolved at the end of lowering, when every instance is known. + if (mightNameBinding(left) || mightNameBinding(right)) + lowerer.dynIdentityComparisons.push({ node, left, right }); +} + +/** Whether an expression could name a binding at all — an identifier or a + * property access, through parentheses and casts. A literal, a call, an + * operator expression: nothing to resolve. */ +function mightNameBinding(node: ts.Node): boolean { + let current: ts.Node | undefined = node; + for (let hops = 0; hops < 16 && current !== undefined; hops++) { + if (ts.isIdentifier(current) || ts.isPropertyAccessExpression(current)) return true; + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + continue; + } + return false; + } + return false; +} + /** The instance a pinned VALUE reference names: the declaration's modes * over the DECLARED types mapped under the bindings (mapType's resolver * substitutes — the instance-body trick), which is the same result the diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..9aeab9358 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -528,6 +528,7 @@ import { inferTypeParamBindings, lowerGenericInstance, genericLocalClassBinding, + checkGenericIdentityPins, } from "./generic-functions.js"; import { bindingNeverReassigned } from "./binding-analysis.js"; import { isMessageListenerCall } from "./expressions/message-listeners.js"; @@ -2572,6 +2573,17 @@ export class Lowerer { * type parameters are bound to `unknown`, so lambdaSignature admits * them. */ readonly erasingLambdas = new Set(); + /** Identity comparisons of a generic declaration met while lowering + * (directly against the declaration, or through a dynamic value): exact + * only while the program holds that declaration as one function object + * (checkGenericIdentityPins). */ + readonly genericIdentityPins: { info: GenericFnInfo; node: ts.Node }[] = []; + /** Identity comparisons whose operands are dynamic values, recorded while + * lowering and resolved by checkGenericIdentityPins only when some + * generic declaration is held at more than one signature. */ + readonly dynIdentityComparisons: { node: ts.Node; left: ts.Node; right: ts.Node }[] = []; + /** The identity comparison check reports once, after all lowering. */ + genericIdentityCheckDone = false; /** Function expressions lowered against a fixed target signature with a * tuple-typed `arguments` (typed-arguments.ts). */ readonly typedArgumentsLambdas = new Set(); @@ -4247,6 +4259,8 @@ export class Lowerer { checkedTable("genericFunctionDemandOwner", () => this.genericFunctionDemandOwner.size), checkedTable("genericClassDemandOwner", () => this.genericClassDemandOwner.size), checkedTable("erasingLambdas", () => this.erasingLambdas.size), + checkedTable("genericIdentityPins", () => this.genericIdentityPins.length), + checkedTable("dynIdentityComparisons", () => this.dynIdentityComparisons.length), checkedTable("typedArgumentsLambdas", () => this.typedArgumentsLambdas.size), checkedTable("tupleAliasSymbols", () => this.tupleAliasSymbols.size), checkedTable("nullishFuncResults", () => this.nullishFuncResults.size), @@ -9636,6 +9650,9 @@ export class Lowerer { if (this.alreadyFlushed.has(symbol)) continue; for (const d of [...diags]) this.pushDiag(d); } + // Coverage visits more bodies; more value instances can only add + // refusals to the identity comparison check. + checkGenericIdentityPins(this); return { module: null, diagnostics: this.diags, @@ -9652,6 +9669,10 @@ export class Lowerer { /** Final retention, pruning, and module assembly shared by ordinary emit * and the retained reachability worklist. */ finishModule(functions: IrFunction[]): LowerResult { + // Every instance of every generic value is known now (the + // monomorphization worklists drained): an identity comparison against + // one is exact only if the declaration is held at a single signature. + checkGenericIdentityPins(this); if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); diff --git a/tests/corpus/generic-identity-pin.ts b/tests/corpus/generic-identity-pin.ts new file mode 100644 index 000000000..88af117ec --- /dev/null +++ b/tests/corpus/generic-identity-pin.ts @@ -0,0 +1,17 @@ +// An unpinned generic declaration compared by identity against a held +// function: the comparison pins the instance the other operand's type +// names, so `h.fn === ident` answers as JavaScript does. +type Fn = (x: number) => number; +function ident(x: T): T { + return x; +} +class Holder { + fn: Fn = ident; +} +const h = new Holder(); +console.log(h.fn === ident, h.fn(3)); +h.fn = (x) => x + 1; +console.log(h.fn === ident, h.fn(3), ident !== h.fn); +const fns: Fn[] = [ident, (x) => x * 2]; +console.log(fns.map((f) => (f === ident ? "ident" : "other")).join(",")); +export {}; diff --git a/tests/corpus/generic-identity-unknown.ts b/tests/corpus/generic-identity-unknown.ts new file mode 100644 index 000000000..39a2507df --- /dev/null +++ b/tests/corpus/generic-identity-unknown.ts @@ -0,0 +1,11 @@ +// A generic declaration held at one signature compares equal to itself +// through `unknown`, and further calls at other signatures (which do not +// hold the declaration as a second function object) stay compilable. +type Fn = (x: number) => number; +function ident(x: T): T { + return x; +} +const f: Fn = ident; +const u: unknown = f; +console.log(u === f, u !== f, f(4)); +console.log(ident(5), ident("s").toUpperCase()); diff --git a/tests/diagnostics/generic-identity-multiple-pins.ts b/tests/diagnostics/generic-identity-multiple-pins.ts new file mode 100644 index 000000000..8d4131589 --- /dev/null +++ b/tests/diagnostics/generic-identity-multiple-pins.ts @@ -0,0 +1,11 @@ +// The program holds `ident` at two signatures and compares a value of one +// against the declaration by identity. JavaScript has one function object; +// the compiled program has one closure per signature, so the comparison is +// refused rather than answered `false`. +type Fn = (x: number) => number; +function ident(x: T): T { + return x; +} +const num: Fn = ident; +const str: (x: string) => string = ident; +console.log(num === ident, str("a")); diff --git a/tests/diagnostics/generic-identity-unknown.ts b/tests/diagnostics/generic-identity-unknown.ts new file mode 100644 index 000000000..2769067c0 --- /dev/null +++ b/tests/diagnostics/generic-identity-unknown.ts @@ -0,0 +1,12 @@ +// One generic declaration held at two signatures compares unequal through +// `unknown` today (one closure per signature, one function object in +// JavaScript). Refuse it instead of answering silently wrong. +type Fn = (x: number) => number; +function ident(x: T): T { + return x; +} +const a: Fn = ident; +const b: (x: string) => string = ident; +const ua = a as unknown; +const ub = b as unknown; +console.log(ua === ub); diff --git a/tests/harness/__snapshots__/generic-identity-multiple-pins.ts.txt b/tests/harness/__snapshots__/generic-identity-multiple-pins.ts.txt new file mode 100644 index 000000000..c7a4de8e6 --- /dev/null +++ b/tests/harness/__snapshots__/generic-identity-multiple-pins.ts.txt @@ -0,0 +1,6 @@ +generic-identity-multiple-pins.ts:11:21 - error SC1090: comparing the generic function 'ident' by identity while the program holds it at 2 different signatures (each is a separate compiled function object; JavaScript has one) — compare against a value pinned once, or hold 'ident' at a single signature are not supported yet + + 10 | const str: (x: string) => string = ident; + 11 | console.log(num === ident, str("a")); + | ^~~~~ + 12 | \ No newline at end of file diff --git a/tests/harness/__snapshots__/generic-identity-unknown.ts.txt b/tests/harness/__snapshots__/generic-identity-unknown.ts.txt new file mode 100644 index 000000000..ac480ebf9 --- /dev/null +++ b/tests/harness/__snapshots__/generic-identity-unknown.ts.txt @@ -0,0 +1,6 @@ +generic-identity-unknown.ts:12:13 - error SC1090: comparing the generic function 'ident' by identity through a dynamic value while the program holds it at 2 different signatures (each is a separate compiled function object; JavaScript has one) — compare against a value pinned once, or hold 'ident' at a single signature are not supported yet + + 11 | const ub = b as unknown; + 12 | console.log(ua === ub); + | ^~~~~~~~~ + 13 | \ No newline at end of file diff --git a/tests/harness/corpus-costs/generic-identity-pin.ts.json b/tests/harness/corpus-costs/generic-identity-pin.ts.json new file mode 100644 index 000000000..35ac2e0bf --- /dev/null +++ b/tests/harness/corpus-costs/generic-identity-pin.ts.json @@ -0,0 +1 @@ +{ "plain": 2213, "san": 49301 } diff --git a/tests/harness/corpus-costs/generic-identity-unknown.ts.json b/tests/harness/corpus-costs/generic-identity-unknown.ts.json new file mode 100644 index 000000000..256338ebe --- /dev/null +++ b/tests/harness/corpus-costs/generic-identity-unknown.ts.json @@ -0,0 +1 @@ +{ "plain": 1172, "san": 59112 } From 8d8926dc6215726768b60512793995ee9305c861 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 08:07:25 -0700 Subject: [PATCH 02/44] Record ts7 preflight/order baselines for the identity corpus Four new fixtures (two corpus programs, two diagnostics programs) get their preflight/module-order answers recorded; no existing baseline changes, so the frontend order behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../order-parity/tests/corpus/generic-identity-pin.ts.json | 6 ++++++ .../tests/corpus/generic-identity-unknown.ts.json | 6 ++++++ .../diagnostics/generic-identity-multiple-pins.ts.json | 6 ++++++ .../tests/diagnostics/generic-identity-unknown.ts.json | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-pin.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-multiple-pins.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-unknown.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-pin.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-pin.ts.json new file mode 100644 index 000000000..ba16cbf0e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-pin.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-identity-pin.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown.ts.json new file mode 100644 index 000000000..699975e88 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-identity-unknown.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-multiple-pins.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-multiple-pins.ts.json new file mode 100644 index 000000000..349c48d0c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-multiple-pins.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/generic-identity-multiple-pins.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-unknown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-unknown.ts.json new file mode 100644 index 000000000..bd3bdce4c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/generic-identity-unknown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/generic-identity-unknown.ts" + ], + "diags": [] +} From 8810d36c5cf0fb4791be9a2728a7c7e16fe7c922 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 08:53:01 -0700 Subject: [PATCH 03/44] Keep the identity pin check inside the self-hosted subset `(info.valueInstances ??= new Set()).add(...)` calls a Set method on the assignment's result, which the compiler fences as a chained Set method call (SC2020) when it compiles its own source: the zero-cost gate builds the candidate compiler with the candidate, and it failed at generic-functions.ts:1358. Record through a helper that spells the same steps as statements. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/generic-functions.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 98d85be69..404bca4a5 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -1331,6 +1331,19 @@ export function lowerGenericInstance( } } +/** Records that `instance` is one of `info`'s value instances (see + * `valueInstances`). Written as its own statements rather than + * `(x ??= new Set()).add(...)`: the self-hosted frontend fences a Set method + * call on a non-Set receiver, and this compiler compiles its own source. */ +function noteValueInstance(info: GenericFnInfo, instance: string): void { + let instances = info.valueInstances; + if (instances === undefined) { + instances = new Set(); + info.valueInstances = instances; + } + instances.add(instance); +} + /** A generic function taken as a VALUE, monomorphized by flow. A function * value needs ONE concrete signature; tsc pins one at exactly two * reference shapes — an instantiation EXPRESSION (`identity`, @@ -1355,7 +1368,7 @@ export function lowerGenericFnValue( // closure per function, so `f === f` holds like any declaration). if (info.implicitParams) { const inst = implicitDefaultInstance(lowerer, ref, info); - (info.valueInstances ??= new Set()).add(inst.name); + noteValueInstance(info, inst.name); const funcType: IrType = funcTypeFromParamShapes(inst.params, inst.returnType); lowerer.requireExactArityValue(ref, ref, inst.params, funcType); lowerer.noteEdge(inst.name); @@ -1559,7 +1572,7 @@ function pinnedGenericClosure( // nothing: mapType answers null for an unsubstituted parameter. if (info.typeParams.some((tp) => !bindings.get(tp))) fenceUnpinned(); const inst = genericValueInstance(lowerer, ref, info, bindings); - (info.valueInstances ??= new Set()).add(inst.name); + noteValueInstance(info, inst.name); // The value's type is the completed ABI signature. Typed rest keeps its // packed-array slot; dynRest stays hidden behind the rest marker. const funcType: IrType = funcTypeFromParamShapes(inst.params, inst.returnType); @@ -1609,7 +1622,7 @@ export function erasedGenericInstance( new Map(info.typeParams.map((tp) => [tp, DYN])), ); instance.erased = true; - (info.valueInstances ??= new Set()).add(instance.name); + noteValueInstance(info, instance.name); return instance; } finally { lowerer.erasingDepth--; From 7dbb3b9c9276deffbcd9c69a36c843e0b509aa61 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 09:22:40 -0700 Subject: [PATCH 04/44] Register the identity check latch in the lowerer's journal The unit journal requires every scalar of the lowerer to be journaled or left out by name; `genericIdentityCheckDone` is set only by the end-of-lowering audit, so a unit must leave it as it was. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lowerer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 9aeab9358..2065a9d78 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4677,6 +4677,9 @@ export class Lowerer { fixed("errorAccessorReflection", () => this.errorAccessorReflection), fixed("subclassLinks", () => this.subclassLinks), fixed("errorPathCopies", () => this.errorPathCopies), + // A latch of the end-of-lowering audit (checkGenericIdentityPins): a + // unit never touches it. + fixed("genericIdentityCheckDone", () => this.genericIdentityCheckDone), fixed("errorMethodDispatchRevision", () => this.errorMethodDispatchRevision), fixed("errorPropertyDispatchRevision", () => this.errorPropertyDispatchRevision), fixed("childAdditions", () => this.childAdditions), From cc417cdc7423937fd57a29c989217bb427db62c9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:46:41 -0700 Subject: [PATCH 05/44] Keep declaration files out of the module order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A specifier that resolves ONLY to a declaration file has no runtime module to link: Node throws ERR_MODULE_NOT_FOUND at it. Preflight already refused the import form (SC1010), but the re-export arm and both require arms took the declaration file as a module-order edge. Once a declaration file is a program module, its own imports are lowered as code — which is how a package's own `.d.mts` files emit SC2013 for every specifier inside them (the pi/typebox shape: 133 errors, all at import statements inside `node_modules/typebox/build/**/*.d.mts`). Refuse the re-export form with the same SC1010 the import form uses, and skip declaration files as edges in both require arms. `splitFiles` gains the matching invariant: a declaration file is never lowered as a runtime module even if one reaches the order by another path. Test: tests/diagnostics/declaration-only-module (SC1010 snapshot) and the recorded preflight/order baselines. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 534ec68eb13f15812376aa5126f013da693a48d6) --- .../src/frontend/lowering/lower-modules.ts | 10 ++++- packages/compiler/src/frontend/program.ts | 38 ++++++++++++++++++- .../declaration-only-module/main.ts.json | 18 +++++++++ .../declaration-only-module/main.ts | 7 ++++ .../declaration-only-module/types.d.ts | 6 +++ .../declaration-only-module/main.ts.txt | 8 ++++ 6 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json create mode 100644 tests/diagnostics/declaration-only-module/main.ts create mode 100644 tests/diagnostics/declaration-only-module/types.d.ts create mode 100644 tests/harness/__snapshots__/declaration-only-module/main.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce58..173b44db4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -328,7 +328,15 @@ function appendEntryModules( * (tests, coverage on broken files) may arrive with an empty order — fall * back to the entry alone. */ export function splitFiles(lowerer: Lowerer): FileParts[] { - const files = lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]; + // A declaration file is never a runtime module: it carries no + // implementation, so it has no %init to lower and its imports never load + // anything. Preflight keeps declarations out of the module order (a + // specifier that resolves only to declarations is refused at its own + // statement); this filter is the invariant that keeps a stray declaration + // from being lowered as code. + const files = (lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]).filter( + (sf) => !sf.isDeclarationFile, + ); return files.map((sf) => { const fp: FileParts = { sf, fnDecls: [], classDecls: [], topStmts: [] }; const rebindable = collectRebindableFunctions(lowerer, sf); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index bcd5e44dc..1b4eb3579 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6060,6 +6060,21 @@ function preflight7(load: LoadResult): { continue; } } + // A re-export whose specifier resolves ONLY to a declaration file + // has no runtime module to link (Node throws + // ERR_MODULE_NOT_FOUND), and a declaration file is not a program + // module: an edge to it would lower the declarations' own imports + // as code. + if (reDep !== null && reDep.isDeclarationFile) { + diags.push( + unsupportedDiag( + "SC1010", + locOf7(stmt), + `the '${fromSpec}' re-export (it resolves only to type declarations — there is no runtime implementation to compile)`, + ), + ); + continue; + } if (reDep && !reDep.fileName.endsWith(".json")) { deps.push({ dep: reDep, stmt }); depPositions.get(sf)!.push({ dep: reDep, pos: ts.tokenStartOfNode(stmt) }); @@ -6250,6 +6265,22 @@ function preflight7(load: LoadResult): { const clause = stmt.importClause; const dep = isRelative ? resolveImport7(program, sf, spec) : (npmStaticDep ?? projDep); const isJson = dep !== null && dep.fileName.endsWith(".json"); + // A specifier that resolves ONLY to a declaration file has no runtime + // module to link: Node throws ERR_MODULE_NOT_FOUND at this import. + // Declarations are not program modules — taking one as a module-order + // edge would lower its imports as if it were code (SC2013 for every + // specifier inside a package's `.d.ts`). Type-only imports were + // already erased above; this is the value form. + if (dep !== null && dep.isDeclarationFile && !isJson) { + diags.push( + unsupportedDiag( + "SC1010", + locOf7(stmt), + `the '${spec}' import (it resolves only to type declarations — there is no runtime implementation to compile)`, + ), + ); + continue; + } // SELF-imports: the bindings alias this module's OWN declarations, // so a TOP-LEVEL read lexically above the aliased declaration is // Node's TDZ ReferenceError at runtime (exactly what tsc's TS2448 @@ -6532,6 +6563,10 @@ function preflight7(load: LoadResult): { } } if (earlyRequireFence(req, bindingKind, loc)) continue; + // A declaration file is never a runtime module (see the import + // arm): the require throws MODULE_NOT_FOUND in Node, so it is not + // a module-order edge. + if (dep !== null && dep.isDeclarationFile) continue; if (dep) deps.push({ dep }); } } @@ -6610,7 +6645,8 @@ function preflight7(load: LoadResult): { lazy ? { dep, lazy: true, call } : { dep, call }; const projectDep = resolveImport7(program, sf, spec, "require"); if (projectDep !== null) { - if (!projectDep.fileName.endsWith(".json")) deps.push(edgeTo(projectDep)); + if (!projectDep.fileName.endsWith(".json") && !projectDep.isDeclarationFile) + deps.push(edgeTo(projectDep)); continue; } if (isRelativeSpecifier(spec) || spec.startsWith("#")) continue; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json new file mode 100644 index 000000000..44a984f57 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/declaration-only-module/main.ts" + ], + "diags": [ + { + "code": "SC1010", + "message": "the './types.js' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet", + "loc": { + "file": "/tests/diagnostics/declaration-only-module/main.ts", + "start": 246, + "end": 281 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] +} diff --git a/tests/diagnostics/declaration-only-module/main.ts b/tests/diagnostics/declaration-only-module/main.ts new file mode 100644 index 000000000..4e507818a --- /dev/null +++ b/tests/diagnostics/declaration-only-module/main.ts @@ -0,0 +1,7 @@ +// A value import whose specifier resolves ONLY to a declaration file has no +// runtime module to link — Node throws ERR_MODULE_NOT_FOUND here, and a +// declaration file is not a program module (its own imports must never be +// lowered as code). +import { value } from "./types.js"; + +console.log(value); diff --git a/tests/diagnostics/declaration-only-module/types.d.ts b/tests/diagnostics/declaration-only-module/types.d.ts new file mode 100644 index 000000000..6daffe15f --- /dev/null +++ b/tests/diagnostics/declaration-only-module/types.d.ts @@ -0,0 +1,6 @@ +// A module that exists only as declarations: it has no runtime +// implementation for either side to link. +export interface Shape { + kind: string; +} +export declare const value: number; diff --git a/tests/harness/__snapshots__/declaration-only-module/main.ts.txt b/tests/harness/__snapshots__/declaration-only-module/main.ts.txt new file mode 100644 index 000000000..40ff9d4db --- /dev/null +++ b/tests/harness/__snapshots__/declaration-only-module/main.ts.txt @@ -0,0 +1,8 @@ +declaration-only-module/main.ts:5:1 - error SC1010: the './types.js' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet + + 4 | // lowered as code). + 5 | import { value } from "./types.js"; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 6 | + + hint: relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported \ No newline at end of file From 678d66ed3e9f9163c13864219f5fd3b9d34036dd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:52:18 -0700 Subject: [PATCH 06/44] Cross declared island arrays as copies, not handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An island export whose DECLARED type is an array of copied values (`string[]`, `string[][]`, `string[] | undefined`) is a handle, so native code that needs a real array — passing it to a native function, spreading it, reading `.length` off a handle binding — was refused with SC8010 (the pi shape: `typ`, a typebox-declared `string[]`, read as a native array at theme-schema.ts:118 and friends). The declared type now decides the crossing, not the use: such a value crosses as `nodeIsland.copyArrays` followed by the checked conversion, so a native function receives a real native array, a nullish arm stays nullish (a non-array crosses as itself), nested arrays copy, and an element that contradicts the declaration throws `TypeError: expected string at $[0], got number` instead of reaching natively typed memory. Copy rather than view: the copy path exists, keeps `Array.isArray` and native iteration, and a view would cost a proxy per array anyway; the identity loss is at the boundary where the copy is visible in the declaration. The handle-binding path is the one that lacked the copy — island member reads and import bindings already crossed this way — so it also had to read the local (not only the global) slot. Test: tests/corpus/6897-node-island-declared-arrays (differential to Node, plain and sanitized) and the lying-island case in tests/harness/node-island-handles.test.ts. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 8045dd9fa1e78b5adc7105589a45387ca885adf0) --- .../frontend/lowering/lower-node-island.ts | 50 ++++++++++++++++--- .../main.ts.json | 6 +++ .../6897-node-island-declared-arrays/main.ts | 40 +++++++++++++++ .../node_modules/array-pkg/index.d.ts | 5 ++ .../node_modules/array-pkg/index.js | 9 ++++ .../node_modules/array-pkg/package.json | 1 + .../package.json | 1 + .../node-island/declared-arrays-lie.ts | 11 ++++ .../main.ts.json | 1 + tests/harness/node-island-handles.test.ts | 36 ++++++++++++- 10 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-declared-arrays/main.ts.json create mode 100644 tests/corpus/6897-node-island-declared-arrays/main.ts create mode 100644 tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.d.ts create mode 100644 tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.js create mode 100644 tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/package.json create mode 100644 tests/corpus/6897-node-island-declared-arrays/package.json create mode 100644 tests/fixtures/node-island/declared-arrays-lie.ts create mode 100644 tests/harness/corpus-costs/6897-node-island-declared-arrays/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index dc49bb99f..ffefc4e15 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -643,7 +643,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr if (!copiedType(lowerer, mapped) && containerType(lowerer, tsType) && handleUseOk(lowerer, node)) return raw; // Otherwise an array of copied values is a copy (above). - if (copiedArrayType(lowerer, mapped)) { + if (copyableArrayType(lowerer, mapped)) { const copy: IrExpr = { kind: "libCall", fn: "nodeIsland.copyArrays", @@ -670,6 +670,31 @@ function copiedArrayType(lowerer: Lowerer, t: IrType): boolean { return copiedType(lowerer, t.elem) || copiedArrayType(lowerer, t.elem); } +/** An array of copied values, or a union of one with null and/or + * undefined (`string[] | undefined`): the copy converts whatever the + * island value holds (a non-array crosses as itself, so a nullish value + * stays nullish) and the checked conversion throws on an element of the + * wrong type. */ +function copyableArrayType(lowerer: Lowerer, t: IrType): boolean { + if (t.kind !== "union") return copiedArrayType(lowerer, t); + const def = lowerer.unions.get(t.unionId); + if (def === undefined) return false; + let arrays = 0; + for (const arm of def.arms) { + if (arm.kind === "nullT" || arm.kind === "undefinedT") continue; + if (!copiedArrayType(lowerer, arm)) return false; + arrays++; + } + return arrays > 0; +} + +/** The IR type an island value crosses as when it is a copyable array + * (`copyableArrayType`), or null when the declared type is not one. */ +function copyableCrossing(lowerer: Lowerer, node: ts.Expression): IrType | null { + const mapped = lowerer.mapTypeOf(lowerer.typeOf(node)); + return mapped !== null && copyableArrayType(lowerer, mapped) ? mapped : null; +} + /** Whether a value of IR type `t` is copied at the boundary: primitives, * strings, null/undefined, and unions of those. */ function copiedType(lowerer: Lowerer, t: IrType): boolean { @@ -1355,17 +1380,30 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | const b = islandBindingOf(lowerer, expr); if (b === null) { if (!islandHandleBinding(lowerer, expr)) return null; - if (!handleUseOk(lowerer, expr)) + // The DECLARED type decides the crossing, not the use: an island + // handle whose type is an array of copied values crosses as a copy of + // its elements wherever native code needs a native value, and the + // checked conversion throws on an element of the wrong type. Every + // other native use stays refused (the value is a handle). + const ok = handleUseOk(lowerer, expr); + const copy = ok ? null : copyableCrossing(lowerer, expr); + if (!ok && copy === null) boundaryFail( lowerer, `an island value of type '${lowerer.checker.typeToString(lowerer.typeOf(expr))}' used as a native value (island arrays, Maps and Sets stay handles: read members, call methods, index or spread them)`, expr, ); lowerer.usesNodeIsland = true; - const g = lowerer.globalOf(expr); - if (g === null) return null; // the local is declared dyn - g.type = DYN; - return varRef(g.id, DYN, loc); + const slot = lowerer.globalOf(expr) ?? lowerer.peekLocal(expr); + if (slot === null) return null; // the local is declared dyn + slot.type = DYN; + const ref = varRef(slot.id, DYN, loc); + return copy === null + ? ref + : lowerer.coerceToExpected( + { kind: "libCall", fn: "nodeIsland.copyArrays", args: [ref], type: DYN, loc }, + copy, + ); } lowerer.usesNodeIsland = true; return fromIsland(lowerer, islandBindingValue(lowerer, b, expr, loc), expr); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-declared-arrays/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-declared-arrays/main.ts.json new file mode 100644 index 000000000..b32c09837 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-declared-arrays/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6897-node-island-declared-arrays/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6897-node-island-declared-arrays/main.ts b/tests/corpus/6897-node-island-declared-arrays/main.ts new file mode 100644 index 000000000..a5c02a23d --- /dev/null +++ b/tests/corpus/6897-node-island-declared-arrays/main.ts @@ -0,0 +1,40 @@ +// @requires-libnode +// An island value whose DECLARED type is an array of copied values crosses +// as a copy of its elements wherever native code needs a native value: the +// declaration decides the crossing, not the use. The copy carries the +// elements the checked conversion then meets, so a native function receives +// a real native array, `string[] | undefined` keeps its nullish arm, and +// nested arrays copy too. +import { empty, list, maybeList, nested } from "array-pkg"; + +/** Native code: it needs a real native array, not an island handle. */ +function describe(values: string[]): string { + let total = 0; + for (const value of values) total += value.length; + return values.length === 0 ? "(none)" : `${values.join("+")} ${total}`; +} + +function count(values: readonly string[]): number { + return values.length; +} + +const names = list(); +console.log(describe(names), count(names), names.length); + +const missing = maybeList(false); +console.log(missing === undefined ? "missing" : describe(missing)); +const present = maybeList(true); +console.log(present === undefined ? "missing" : describe(present)); + +const fallback = maybeList(false) ?? []; +console.log(describe(fallback), fallback.length); + +console.log(describe(empty()), describe(nested()[0]!), count(nested()[1]!)); + +// The crossing copies: a native array the program sorts is its own, and a +// fresh read of the island's array is unaffected. +const first = list(); +const second = list(); +const sortedNative: string[] = [...first]; +sortedNative.sort(); +console.log(sortedNative.join(","), second.join(","), describe(first)); diff --git a/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.d.ts b/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.d.ts new file mode 100644 index 000000000..355a117d6 --- /dev/null +++ b/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.d.ts @@ -0,0 +1,5 @@ +export declare function list(): string[]; +export declare function maybeList(on: boolean): string[] | undefined; +export declare function nested(): string[][]; +export declare function empty(): string[]; +export declare function lie(): string[]; diff --git a/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.js b/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.js new file mode 100644 index 000000000..a01de0268 --- /dev/null +++ b/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/index.js @@ -0,0 +1,9 @@ +"use strict"; + +const names = ["ada", "grace", "linus"]; + +exports.list = () => names.slice(); +exports.maybeList = (on) => (on ? ["x", "yy"] : undefined); +exports.nested = () => [["a", "b"], ["c"]]; +exports.empty = () => []; +exports.lie = () => [1, 2]; diff --git a/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/package.json b/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/package.json new file mode 100644 index 000000000..4e0cecf09 --- /dev/null +++ b/tests/corpus/6897-node-island-declared-arrays/node_modules/array-pkg/package.json @@ -0,0 +1 @@ +{ "name": "array-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6897-node-island-declared-arrays/package.json b/tests/corpus/6897-node-island-declared-arrays/package.json new file mode 100644 index 000000000..6d032903d --- /dev/null +++ b/tests/corpus/6897-node-island-declared-arrays/package.json @@ -0,0 +1 @@ +{ "name": "island-declared-arrays", "private": true, "type": "module", "scriptc": { "packages": { "array-pkg": "node" } } } diff --git a/tests/fixtures/node-island/declared-arrays-lie.ts b/tests/fixtures/node-island/declared-arrays-lie.ts new file mode 100644 index 000000000..e91b47ec8 --- /dev/null +++ b/tests/fixtures/node-island/declared-arrays-lie.ts @@ -0,0 +1,11 @@ +// `lie()` declares `string[]` and hands back numbers: the crossing copies +// the island's array and the checked conversion throws on an element that +// contradicts the declaration, so no number ever reaches a native string[]. +import { lie } from "array-pkg"; + +function first(values: string[]): string { + return values[0] ?? ""; +} + +console.log("before"); +console.log(first(lie())); diff --git a/tests/harness/corpus-costs/6897-node-island-declared-arrays/main.ts.json b/tests/harness/corpus-costs/6897-node-island-declared-arrays/main.ts.json new file mode 100644 index 000000000..d535beaac --- /dev/null +++ b/tests/harness/corpus-costs/6897-node-island-declared-arrays/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 32000 } diff --git a/tests/harness/node-island-handles.test.ts b/tests/harness/node-island-handles.test.ts index 4b8c6df62..650036bf4 100644 --- a/tests/harness/node-island-handles.test.ts +++ b/tests/harness/node-island-handles.test.ts @@ -6,7 +6,15 @@ * ships with the program, so the executable runs without the tree it was * built from. Needs a libnode pack in SCRIPTC_LIBNODE (libnode.ts). */ import { execFile } from "node:child_process"; -import { cpSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs"; +import { + cpSync, + copyFileSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; @@ -176,6 +184,32 @@ describe.skipIf(skip)("Node island handles", () => { } }, 300_000); + test("an island array that contradicts its declaration throws at the crossing", async () => { + // The crossing copies the island's array element-wise, and the checked + // conversion throws on an element of the wrong type: `lie()` declares + // string[] and hands back numbers, so no number reaches a native + // string[]. (A corpus program cannot pin this: Node prints the numbers.) + const root = join(outDir, "declared-arrays-lie"); + rmSync(root, { recursive: true, force: true }); + cpSync(join(repoRoot, "tests/corpus/6897-node-island-declared-arrays"), root, { + recursive: true, + }); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/declared-arrays-lie.ts"), + join(root, "main.ts"), + ); + const result = await compile(join(root, "main.ts"), { + outPath: join(root, "main"), + outDir: root, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(await refusal(result.binaryPath)).toContain( + "TypeError: expected string at $[0], got number", + ); + }, 300_000); + test("an island package ships with the program", async () => { const root = join(outDir, "ships"); const src = join(root, "src"); From 678ddf8a4e5193dd78230368f7fd0f833d1e4587 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:53:13 -0700 Subject: [PATCH 07/44] Island user program modules end to end A file the island decision put in the set is no longer compiled natively: it is keyed by its absolute path, its declarations type the program as foreign, its diagnostics do not fail the program the build does not compile, and every import or require of it lowers to an island load. `--node-island=` therefore takes effect for a user module, not only for a package. The recovery stays one round: when the lowering refuses a user JavaScript module, the reload adds every such module at once and each one is reported once as an SC8001 note naming the module and its reason. The build reports the islands it ran (packages and modules) with its result, and an executable cache hit reports them again, so a runner can tell an island result from a native one. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/compile-types.ts | 4 + .../compiler/src/diagnostics/diagnostic.ts | 16 ++ .../src/executable/executable-cache.ts | 16 ++ packages/compiler/src/executable/prepare.ts | 157 ++++++++++++++++-- .../src/frontend/lowering/lower-modules.ts | 4 + .../frontend/lowering/lower-node-island.ts | 52 +++++- .../frontend/lowering/lower-shared-modules.ts | 4 + packages/compiler/src/frontend/pipeline.ts | 43 ++++- packages/compiler/src/index.ts | 38 ++++- .../compiler/src/node-islands/boundary.ts | 43 ++++- packages/compiler/src/node-islands/policy.ts | 5 +- 11 files changed, 346 insertions(+), 36 deletions(-) diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 404648051..29a34660d 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -165,6 +165,10 @@ export type CompileFailure = { export interface CompileWarnings { warnings?: ScrDiagnostic[]; sourceTexts?: Map; + /** The Node islands the build ran: the npm packages and the program + * modules (absolute file paths). Reported again on an executable cache + * hit, so a runner can tell an island result from a native one. */ + islands?: { packages: string[]; modules: string[] }; } export type CompileSourceResult = diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index a5f4fdbe2..a1b2f071f 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -453,6 +453,22 @@ export function nodeIslandNoteDiag( }; } +/** SC8001 — a program module runs in a Node island (a note, once per island + * module in the build output; DESIGN.md note 1 §3.2). */ +export function nodeIslandModuleNoteDiag( + file: string, + klass: string, + reason: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8001", + message: `module '${file}' runs in a Node island: ${klass}: ${reason}`, + loc, + hint: "sloppy-mode code Node runs; --node-islands=off compiles this module natively instead", + }; +} + /** SC8010 — a value crosses an island boundary in a way NI-4 cannot keep * exact yet (callbacks, native objects, remote promises). */ export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic { diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 604af05b9..491cc5fa2 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -147,6 +147,8 @@ interface EarlyExecutableCacheStamp { native: EarlyExecutableNativeFeatures; /** The build's performance warnings (SC61xx), which a cache hit repeats. */ performance?: ScrDiagnostic[]; + /** The Node islands the build ran, which a cache hit reports again. */ + islands?: { packages: string[]; modules: string[] }; /** Programs that call into Node.js: the libnode pack identity the build * recorded (EarlyExecutableCacheOptions.libnodeIdentity). */ libnode?: string; @@ -259,6 +261,8 @@ export interface EarlyExecutableCacheHit { frontend: FrontendInputSnapshot; /** The performance warnings the build reported (SC61xx), repeated on a hit. */ performance?: ScrDiagnostic[]; + /** The Node islands the build ran, repeated on a hit. */ + islands?: { packages: string[]; modules: string[] }; /** How a --profile-use build's profile matched, reported again on a hit. */ profileMatch?: NativeProfileMatch; } @@ -267,6 +271,16 @@ export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit { nativeDependencies?: NativeArtifactDependency[]; } +function isIslands(value: unknown): value is { packages: string[]; modules: string[] } { + if (typeof value !== "object" || value === null) return false; + const islands = value as Record; + return ( + Array.isArray(islands.packages) && + Array.isArray(islands.modules) && + [...islands.packages, ...islands.modules].every((item) => typeof item === "string") + ); +} + function isProfileMatch(value: unknown): value is NativeProfileMatch { if (typeof value !== "object" || value === null) return false; const match = value as Record; @@ -745,6 +759,7 @@ async function readExecutableCache( ...(Array.isArray(stamp.performance) && stamp.performance.length > 0 ? { performance: stamp.performance } : {}), + ...(isIslands(stamp.islands) ? { islands: stamp.islands } : {}), ...(isProfileMatch(stamp.profileMatch) ? { profileMatch: stamp.profileMatch } : {}), ...(ir === null ? {} : { irPath: paths.irPath }), ...(llvmSourcePath === undefined ? {} : { llvmSourcePath }), @@ -936,6 +951,7 @@ export async function publishEarlyExecutableCache( ...(result.performance === undefined || result.performance.length === 0 ? {} : { performance: result.performance }), + ...(result.islands === undefined ? {} : { islands: result.islands }), ...(result.native.nodeIsland === true ? { libnode: options.libnodeIdentity?.() ?? "" } : {}), ...(result.profileMatch === undefined ? {} : { profileMatch: result.profileMatch }), }; diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index a85e17f37..fc6484438 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -1,8 +1,10 @@ +import { realpathSync } from "node:fs"; import type { CompileFailure, CompileRequestOptions } from "../compile-types.js"; import type { FfiProfile } from "../ffi/ffi-manifest.js"; import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, + nodeIslandModuleNoteDiag, nodeIslandNoteDiag, nodeIslandRetryFailedDiag, npmStaticRefusalDiag, @@ -24,15 +26,20 @@ import type { FrontendFactory } from "../frontend/pipeline.js"; import type { UnitStore } from "../frontend/lowering/unit-reuse.js"; import type { CompilationTiming } from "../timing.js"; import { readProjectConfig } from "../project-config.js"; -import { nodeIslandPolicy } from "../node-islands/policy.js"; +import { + isMarkedUserModule, + nodeIslandPolicy, + type NodeIslandPolicy, +} from "../node-islands/policy.js"; import { buildNodeIslandReport } from "../node-islands/report.js"; -import type { IslandPackage, NodeIslandSet } from "../node-islands/boundary.js"; +import type { IslandModule, IslandPackage, NodeIslandSet } from "../node-islands/boundary.js"; import { classifyDiagnostic, isIslandReasonCode } from "../node-islands/classify.js"; import { packageDirOf, packageVersionOf } from "../node-islands/origin.js"; import { describe } from "../node-islands/summary.js"; import { resolveBareModule } from "../frontend/resolve.js"; import { isEsmFile } from "../frontend/lowering/lower-node-island.js"; -import type { PackageEdge } from "../node-islands/scan.js"; +import type { NodeIslandScan, PackageEdge } from "../node-islands/scan.js"; +import { isJsSourceFileName } from "../frontend/tsc-codes.js"; export interface PreparedExecutableModule { ok: true; @@ -42,6 +49,10 @@ export interface PreparedExecutableModule { /** Divergence warnings (SC6xxx): the build succeeds; these sites can * behave differently from Node. */ warnings: ScrDiagnostic[]; + /** The Node islands the build ran (the SC8001 notes name them with their + * reasons): packages by name, modules by file. A cached build repeats + * them. */ + islands?: { packages: string[]; modules: string[] }; } /** Release the parser/checker before native object generation begins. @@ -57,6 +68,7 @@ export function prepareExecutableModule( lowerIslands: ReadonlyMap = new Map(), retryFences = true, unitStore: UnitStore | null = null, + lowerModules: ReadonlyMap = new Map(), ): PreparedExecutableModule | CompileFailure { // The island decision reads the detector's scan unless islands are off. const islandScan = @@ -66,11 +78,14 @@ export function prepareExecutableModule( // Node islands (scope-B NI-4): packages refused natively run in Node, // unless the policy says otherwise. The frontend reloads without them, // so their declarations type the program. - const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands); + const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands, lowerModules); if (islandDecision !== null) { fe.dispose(); fe = createFrontend(entryPath, opts.npmStatic, undefined, undefined, { islandPackages: new Set(islandDecision.packages.keys()), + ...(islandDecision.modules.size > 0 + ? { islandModules: new Set(islandDecision.modules.keys()) } + : {}), islandScan, }); timing("frontend-island-reload"); @@ -121,6 +136,7 @@ export function prepareExecutableModule( grown, retryFences, unitStore, + lowerModules, ); } return fail(fe.preflight); @@ -159,7 +175,11 @@ export function prepareExecutableModule( lowerIslands, fe.nodeIslandScan?.({ userJs: false }).edges, ); - if (grown !== null) { + // Sloppy user modules the lowering refused join the same retry + // (DESIGN.md note 1 §3.2): one reload with every island at once, + // never one round per module. + const grownModules = loweringModules(entryPath, opts, lowered.diagnostics, lowerModules); + if (grown !== null || grownModules !== null) { fe.dispose(); frontendDisposed(); disposed = true; @@ -170,9 +190,10 @@ export function prepareExecutableModule( buildPlatform, createFrontend, timing, - grown, + grown ?? lowerIslands, retryFences, unitStore, + grownModules ?? lowerModules, ); } // A failed program whose compiled packages need the runtime or a @@ -198,6 +219,8 @@ export function prepareExecutableModule( timing, marked, false, + undefined, + lowerModules, ); } return fail(lowered.diagnostics); @@ -258,6 +281,14 @@ export function prepareExecutableModule( ...(lowered.divergences ?? []), ...(lowered.performance ?? []), ], + ...(islandDecision === null + ? {} + : { + islands: { + packages: [...islandDecision.packages.keys()].sort(), + modules: [...islandDecision.modules.keys()].sort(), + }, + }), }; // Packages compiled with runtime fences that need the JavaScript // runtime or a mutable object model (fenceIslands): build again with @@ -284,6 +315,7 @@ export function prepareExecutableModule( islands, false, unitStore, + lowerModules, ); if (islanded.ok) return islanded; firstError ??= islanded.diagnostics[0] ?? null; @@ -298,16 +330,63 @@ export function prepareExecutableModule( return prepared; } -/** The packages a build runs in Node islands, or null for none: under - * `--node-islands=auto` (the default) every npm package the native - * pipeline refused, plus packages marked `node`; under `explicit` only the - * marked ones; under `off`, none (refusals stay SC2013). Each island is - * reported once as an SC8001 note. */ +/** Whether two spellings name the same file (the checker resolves through + * symlinks; a CLI argument may not). */ +function sameFile(a: string, b: string): boolean { + if (samePath(a, b)) return true; + try { + const real = realpathSync(a); + const other = realpathSync(b); + return samePath(real, other); + } catch { + return false; + } +} + +function samePath(a: string, b: string): boolean { + return a.replaceAll("\\", "/") === b.replaceAll("\\", "/"); +} + +/** The program modules that run in Node islands, by file: modules marked + * with `--node-island=` or `scriptc.nodeIslands.modules`. The entry + * stays native: a program whose entry is an island runs the whole file in + * Node, which is a different feature. */ +function moduleIslands( + entryPath: string, + policy: NodeIslandPolicy, + scan: NodeIslandScan, +): Map { + const modules = new Map(); + for (const m of scan.modules) { + if (m.origin !== "user") continue; + if (!isJsSourceFileName(m.file)) continue; + if (sameFile(m.file, entryPath)) continue; + const marked = isMarkedUserModule(policy, m.file); + if (marked === null) continue; + modules.set(m.file, { + file: m.file, + class: "marked", + reason: + marked === "cli" + ? "marked with --node-island" + : 'marked "node" in scriptc.nodeIslands.modules', + decision: "marked", + }); + } + return modules; +} + +/** The packages and modules a build runs in Node islands, or null for none: + * under `--node-islands=auto` (the default) every npm package the native + * pipeline refused, plus the packages and modules marked `node`; under + * `explicit` only the marked ones; under `off`, none (refusals stay + * SC2013). Each island is reported once as an SC8001 note. */ function decideNodeIslands( entryPath: string, opts: CompileRequestOptions, fe: ReturnType, lowerIslands: ReadonlyMap, + lowerModules: ReadonlyMap, ): (NodeIslandSet & { notes: ScrDiagnostic[] }) | null { const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); if (policy.mode === "off" || fe.nodeIslandScan === undefined) return null; @@ -321,10 +400,15 @@ function decideNodeIslands( : [], ); for (const name of lowerIslands.keys()) refused.add(name); - if (refused.size === 0 && policy.forcedNode.size === 0) return null; + const scan = fe.nodeIslandScan({ userJs: false }); + const modules = moduleIslands(entryPath, policy, scan); + // Modules the lowering refused keep their first reason (the reload no + // longer compiles them). + for (const [file, island] of lowerModules) if (!modules.has(file)) modules.set(file, island); + if (refused.size === 0 && policy.forcedNode.size === 0 && modules.size === 0) return null; const report = buildNodeIslandReport({ entry: entryPath, - scan: fe.nodeIslandScan({ userJs: false }), + scan, npm: fe.npmStatic, diagnostics: [], fences: [], @@ -397,7 +481,17 @@ function decideNodeIslands( ), ); } - return packages.size === 0 ? null : { packages, notes }; + for (const island of [...modules.values()].sort((a, b) => + a.file < b.file ? -1 : a.file > b.file ? 1 : 0, + )) + notes.push( + nodeIslandModuleNoteDiag(island.file, island.class, island.reason, { + file: island.file, + start: 0, + end: 0, + }), + ); + return packages.size === 0 && modules.size === 0 ? null : { packages, modules, notes }; } /** The island set grown by the npm packages whose own modules refused to @@ -478,6 +572,41 @@ function loweringIslands( return grown.size === known.size ? null : grown; } +/** The island modules grown by the lowering's refusals in user JavaScript + * (`--node-islands=auto` only), or null when there are none: a sloppy + * module whose semantics the native pipeline refuses (an implicit global, + * a mapped `arguments`, a `with` the pre-scan could not decide) runs in + * Node. One retry adds every one of them at once. */ +function loweringModules( + entryPath: string, + opts: CompileRequestOptions, + diagnostics: readonly ScrDiagnostic[], + known: ReadonlyMap, +): Map | null { + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + if (policy.mode !== "auto" || known.size > 16) return null; + const grown = new Map(known); + for (const d of diagnostics) { + if (!isIslandReasonCode(d.code)) continue; + const file = d.loc.file; + // Packages run in Node through their own decision (loweringIslands). + if (file.includes("/node_modules/") || packageDirOf(file) !== null) continue; + if (!isJsSourceFileName(file) || sameFile(file, entryPath)) continue; + if (grown.has(file)) continue; + const reason = classifyDiagnostic(d, "refusal"); + // A gap is a construct scriptc does not implement, not a sloppy + // semantic: running it in Node would hide a compiler limitation. + if (reason.class === "gap") continue; + grown.set(file, { + file, + class: reason.class, + reason: describe(reason), + decision: "auto", + }); + } + return grown.size === known.size ? null : grown; +} + /** Preflight refusals at import statements of natively compiled npm * packages (`--node-islands=auto` only): those packages become islands. */ function preflightIslands( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce58..6091b8ce4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -665,6 +665,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { // graph: resolveImport follows its node_modules link to the loaded // TypeScript entry. Its internal `.js` spellings have already been // resolved to `.ts` by that graph. + // A relative import of a program module the pre-scan put in a Node + // island (DESIGN.md note 1 §3.2): the island loads at this module's + // position and its bindings are island values. + if (lowerer.nodeIslands !== null && collectIslandImport(lowerer, fp.sf, stmt, spec)) continue; const projectDep = isRelativeSpecifier(spec) ? null : resolveImport(lowerer.program, fp.sf, spec); diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index dc49bb99f..742b47d4b 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -16,11 +16,12 @@ * checked against the declared result type, like `JSON.parse(s) as T`; a * thrown Error crosses as its nearest intrinsic class with its message and * string `code`. */ -import { readFileSync, realpathSync } from "node:fs"; +import { readFileSync, realpathSync, statSync } from "node:fs"; import { resolveFrom } from "../../node-islands/resolve.js"; -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import * as ts from "../ts7/adapter.js"; import { isJsSourceFile, locOf } from "../program.js"; +import { isRelativeSpecifier } from "../workspace-registry.js"; import type { NodeApiMember } from "../node-api-import.js"; import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js"; import { @@ -49,7 +50,7 @@ import { isForeignType, isIslandSubclassDecl, markIslandSubclass, - islandPackageOfFile, + islandOwnerOfFile, type NodeIslandSet, } from "../../node-islands/boundary.js"; import { stripTypeWrappers } from "./typed-arguments.js"; @@ -288,21 +289,54 @@ export function isEsmFile(file: string): boolean { } } +/** The file a relative (or absolute) specifier names from `fromFile`, as + * Node's CommonJS/ESM resolution would find it, or null. Extension fallback + * and directory indexes only: an island module is a file the program + * already loaded, and `exports` maps stay Node's job. */ +function relativeIslandFile(fromFile: string, spec: string): string | null { + const base = isAbsolute(spec) ? spec : resolve(dirname(fromFile), spec); + const candidates = [base]; + if (!/\.[cm]?js$/.test(base)) { + for (const ext of [".js", ".cjs", ".mjs"]) candidates.push(base + ext); + } + for (const ext of [".js", ".cjs", ".mjs"]) candidates.push(join(base, `index${ext}`)); + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + /* the next candidate */ + } + } + return null; +} + /** The island module file `spec` names from `fromFile`, or null when the - * specifier does not resolve into an island package. */ + * specifier does not resolve into an island package or module. */ export function islandModuleFile( islands: NodeIslandSet, fromFile: string, spec: string, kind: "import" | "require", ): string | null { - const js = resolveBareModule(fromFile, spec, "js-only", kind); - if (js === null) return null; - if (islandPackageOfFile(islands, js.typesFile) === null) return null; + let file: string; + if (isRelativeSpecifier(spec) || isAbsolute(spec)) { + // A program module the pre-scan put in a Node island (DESIGN.md note 1 + // §3.2): the file itself is the island, resolved as Node would. + if (islands.modules.size === 0) return null; + const resolved = relativeIslandFile(fromFile, spec); + if (resolved === null) return null; + if (islandOwnerOfFile(islands, resolved) === null) return null; + file = resolved; + } else { + const js = resolveBareModule(fromFile, spec, "js-only", kind); + if (js === null) return null; + if (islandOwnerOfFile(islands, js.typesFile) === null) return null; + file = js.typesFile; + } try { - return realpathSync(js.typesFile); + return realpathSync(file); } catch { - return js.typesFile; + return file; } } diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index ea0cfb702..ecae17894 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -415,6 +415,10 @@ export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] { } for (const island of lowerer.nodeIslands?.packages.values() ?? []) packageJsFiles(island.dir, queue); + // A program module the build runs in a Node island is loaded by Node, so + // the program modules IT imports are shared with it like an island + // package's (DESIGN.md note 1 §3.2). + for (const module of lowerer.nodeIslands?.modules.values() ?? []) queue.push(real(module.file)); if (queue.length === 0 && candidates.size === 0) return plans; const seen = new Set(); // Classes island code extends, by the compiled module that exports them. diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index ab3e5cc83..cbd1fb238 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -146,6 +146,10 @@ export interface FrontendOptions { /** Packages that run in Node islands (scope-B NI-4): never compiled, * typed by their declarations. */ islandPackages?: ReadonlySet; + /** Program modules that run in Node islands (DESIGN.md note 1 §3.2): + * their bodies are never compiled; their imports lower to island loads. + * Absolute file paths. */ + islandModules?: ReadonlySet; /** The Node island report (coverage): the project's scriptc settings and * the detector's scan of the program and its npm packages. Builds do not * read either. */ @@ -565,6 +569,11 @@ export function runFrontend( } } +/** A file path as the frontend and the island decision spell it. */ +function normalizeFile(file: string): string { + return file.replaceAll("\\", "/"); +} + /** Rounds of dropping single refused overlay annotations before the * fallback drops whole files' overlays. */ const OVERLAY_ANNOTATION_ROUNDS = 4; @@ -586,6 +595,18 @@ function loadFrontend( beginShapeOverlayBuild(); const statuses: NpmStaticStatus[] = []; const npmSites = new Map(); + // Program modules that run in Node islands (DESIGN.md note 1 §3.2): their + // bodies are not lowered, so their sloppy semantics stay Node's, and a + // diagnostic anchored in one does not fail the program the build does not + // compile. + const islandModules = + options.islandModules === undefined || options.islandModules.size === 0 + ? null + : new Set([...options.islandModules].map(normalizeFile)); + const inIslandModule = (loc: SrcLoc | undefined): boolean => + islandModules !== null && loc !== undefined && islandModules.has(normalizeFile(loc.file)); + const withoutIslandModules = (diags: ScrDiagnostic[]): ScrDiagnostic[] => + islandModules === null ? diags : diags.filter((d) => !inIslandModule(d.loc)); // Node island detection (the report, or a build that may run islands): // how each package was refused, and the modules of packages removed from // the program. @@ -848,8 +869,9 @@ function loadFrontend( const patchDiagnostics = typePatchDiagnostics()?.diagnostics ?? []; const buildWarnings = [...(report ? [] : configDiagnostics), ...patchDiagnostics]; return { - preflight: + preflight: withoutIslandModules( report && configDiagnostics.length > 0 ? [...configDiagnostics, ...preflight] : preflight, + ), ...(buildWarnings.length > 0 ? { warnings: buildWarnings } : {}), entryText: () => finalLoad.entry.text, entryExports: () => entryFunctionExports(finalLoad.entry), @@ -896,9 +918,22 @@ function loadFrontend( return texts; }, lower: (opts, valid) => { + // The module order without the island modules, computed once per load: + // a reload keeps the array the lowering appends its dynamic import + // modules to. + const islandFiltered = new WeakMap(); + const lowerOrder = (l: typeof finalLoad): SourceFile[] => { + if (islandModules === null) return l.moduleOrder; + let kept = islandFiltered.get(l.moduleOrder); + if (kept === undefined) { + kept = l.moduleOrder.filter((sf) => !islandModules.has(normalizeFile(sf.fileName))); + islandFiltered.set(l.moduleOrder, kept); + } + return kept; + }; const lowerLoad = (l: typeof finalLoad): LowerResult => { if (l.shapeOverlayState !== undefined) restoreShapeOverlayState(l.shapeOverlayState); - return lowerToIr(l.program, l.entry, l.moduleOrder, { + return lowerToIr(l.program, l.entry, lowerOrder(l), { ...opts, frontendServices: l.services, startupCrash: l.startupCrash ?? null, @@ -964,7 +999,7 @@ function loadFrontend( const t1 = performance.now(); // The preflight fills the module order; the tsc world reads the // files as written, so it answers as the first load did. - const reloadPreflight = checkPreflight(finalLoad); + const reloadPreflight = withoutIslandModules(checkPreflight(finalLoad)); if (reloadPreflight.length > 0) return { ...lowered, module: null, diagnostics: reloadPreflight }; const t2 = performance.now(); @@ -1066,7 +1101,7 @@ function loadFrontend( setShapeOverlayDropped(true); finalLoad.dispose(); finalLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes }); - const reloadPreflight = checkPreflight(finalLoad); + const reloadPreflight = withoutIslandModules(checkPreflight(finalLoad)); if (reloadPreflight.length > 0) return { ...lowered, module: null, diagnostics: reloadPreflight }; lowered = lowerOnce(); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 34ea20b28..927b56e68 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -539,10 +539,11 @@ export async function compile( } /** Divergence warnings collected while a compile lowers (none when a cache - * hit skips lowering). */ + * hit skips lowering), and the Node islands the build ran. */ interface CompileWarningSink { warnings: ScrDiagnostic[]; sourceTexts: Map | null; + islands?: { packages: string[]; modules: string[] }; } /** The package.json texts type patch warnings point into, re-read for a @@ -560,6 +561,15 @@ function cachedWarningSources(warnings: readonly ScrDiagnostic[]): Map( result: T, sink: CompileWarningSink, ): T { - if (!result.ok || sink.warnings.length === 0) return result; - return { ...result, warnings: sink.warnings, sourceTexts: sink.sourceTexts ?? new Map() }; + if (!result.ok) return result; + const islands = sink.islands === undefined ? {} : { islands: sink.islands }; + if (sink.warnings.length === 0) return { ...result, ...islands }; + return { + ...result, + warnings: sink.warnings, + sourceTexts: sink.sourceTexts ?? new Map(), + ...islands, + }; } /** Build-time API compatibility fence: a caller's existing CompileOptions @@ -1026,6 +1043,9 @@ interface PreparedExecutable { nativeFeatures: EarlyExecutableNativeFeatures; programSplit: ReturnType; sourceTexts: Map; + /** The Node islands the build ran, reported with its result (a runner + * reads them to tell an island result from a native one). */ + islands?: { packages: string[]; modules: string[] }; } /** Finish frontend work in a separate scope so the AST, checker caches and @@ -1103,6 +1123,8 @@ async function prepareExecutableInput( warningSink.warnings = prepared.warnings; warningSink.sourceTexts = sourceTexts; } + if (warningSink !== undefined && prepared.islands !== undefined) + warningSink.islands = prepared.islands; const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); const defaultSourcePaths = { @@ -1262,6 +1284,7 @@ async function prepareExecutableInput( nativeFeatures, programSplit, sourceTexts, + ...(prepared.islands === undefined ? {} : { islands: prepared.islands }), }; } @@ -1447,6 +1470,7 @@ type FrontendProcessResult = nativeFeatures: EarlyExecutableNativeFeatures; sourceTexts: [string, string][]; warnings: ScrDiagnostic[]; + islands?: { packages: string[]; modules: string[] }; frontend: FrontendInputSnapshot; } | { @@ -1507,6 +1531,8 @@ async function prepareExecutableInputInChild( warningSink.warnings = result.warnings; warningSink.sourceTexts = sourceTexts; } + if (warningSink !== undefined && result.kind === "prepared" && result.islands !== undefined) + warningSink.islands = result.islands; if (result.kind === "diagnostics") return { ok: false, diagnostics: result.diagnostics, sourceTexts }; return { @@ -1518,6 +1544,7 @@ async function prepareExecutableInputInChild( nativeFeatures: result.nativeFeatures, programSplit: null, sourceTexts, + ...(result.islands === undefined ? {} : { islands: result.islands }), }; } @@ -1595,6 +1622,7 @@ export async function runFrontendProcess(requestPath: string, resultPath: string nativeFeatures: prepared.nativeFeatures, sourceTexts: [...prepared.sourceTexts], warnings: warnings.warnings, + ...(prepared.islands === undefined ? {} : { islands: prepared.islands }), frontend, }; } else if (!prepared.ok) { @@ -2007,6 +2035,8 @@ async function compileTracked( warningSink.warnings = earlyHit.performance; warningSink.sourceTexts = cachedWarningSources(earlyHit.performance); } + if (warningSink !== undefined && earlyHit.islands !== undefined) + warningSink.islands = earlyHit.islands; const executableCacheOptions = earlyCacheOptions; // Route/proof metadata is independently evictable. A full-compiler // fallback that still finds the validated payload repairs that lightweight @@ -2253,6 +2283,7 @@ async function compileTracked( frontend: frontendInputs.snapshot(), ...(irSourcePath === undefined ? {} : { irPath: irSourcePath }), ...performanceWarnings(warningSink), + ...islandFields(warningSink), ...profileMatchField(nativeProgramObject), }); publishedExecutable = true; @@ -2275,6 +2306,7 @@ async function compileTracked( frontend: frontendInputs.snapshot(), ...(irSourcePath === undefined ? {} : { irPath: irSourcePath }), ...performanceWarnings(warningSink), + ...islandFields(warningSink), ...profileMatchField(nativeProgramObject), }).catch(() => undefined); } diff --git a/packages/compiler/src/node-islands/boundary.ts b/packages/compiler/src/node-islands/boundary.ts index be4339391..bd317e23f 100644 --- a/packages/compiler/src/node-islands/boundary.ts +++ b/packages/compiler/src/node-islands/boundary.ts @@ -28,9 +28,45 @@ export interface IslandPackage { reason: string; } -/** The program's island packages, by package name. */ +/** One program module the build runs in a Node island (DESIGN.md + * note 1 §3.2): a user JavaScript file whose semantics need the JavaScript + * engine, keyed by its absolute path. */ +export interface IslandModule { + /** The module file (absolute, as the checker spells it). */ + file: string; + /** The island class (runtime, object-model, gap, packaging) and the first + * reason, for the SC8001 note. */ + class: string; + reason: string; + /** Marked by the user (`--node-island`, `nodeIslands.modules`) or + * detected by the pre-scan under `--node-islands=auto`. */ + decision: "marked" | "auto"; +} + +/** The program's island packages, by package name, and island modules, by + * absolute file path. */ export interface NodeIslandSet { packages: ReadonlyMap; + modules: ReadonlyMap; +} + +function normalizePath(file: string): string { + return file.replaceAll("\\", "/"); +} + +/** The island module at `file`, or null. */ +export function islandModuleOfFile(islands: NodeIslandSet, file: string): IslandModule | null { + if (islands.modules.size === 0) return null; + return islands.modules.get(file) ?? islands.modules.get(normalizePath(file)) ?? null; +} + +/** The island (package or module) that owns a declaration or JavaScript + * file, or null. Modules first: a user module is never inside a package. */ +export function islandOwnerOfFile( + islands: NodeIslandSet, + file: string, +): IslandPackage | IslandModule | null { + return islandModuleOfFile(islands, file) ?? islandPackageOfFile(islands, file); } /** The island package that owns a declaration or JavaScript file, or null. @@ -65,7 +101,7 @@ function declaredInIsland(islands: NodeIslandSet, symbol: ts.Symbol | null | und if (decls.length === 0) return false; if (islandSubclassDecls.size > 0 && decls.some((d) => islandSubclassDecls.has(d))) return true; return decls.every( - (d) => islandPackageOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null, + (d) => islandOwnerOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null, ); } @@ -150,7 +186,8 @@ export function crossingOf( let active: NodeIslandSet | null = null; export function setActiveNodeIslands(islands: NodeIslandSet | null): void { - active = islands !== null && islands.packages.size > 0 ? islands : null; + active = + islands !== null && (islands.packages.size > 0 || islands.modules.size > 0) ? islands : null; } export function activeNodeIslands(): NodeIslandSet | null { diff --git a/packages/compiler/src/node-islands/policy.ts b/packages/compiler/src/node-islands/policy.ts index 72dbf3cd0..945bdfd22 100644 --- a/packages/compiler/src/node-islands/policy.ts +++ b/packages/compiler/src/node-islands/policy.ts @@ -4,9 +4,8 @@ * become islands only when marked (`nodeIslands.modules`, * `--node-island=`); `"native"` (`--native=`) makes one * dependency strict; `--node-islands=off` makes the whole program strict. - * - * This version is report-only: the policy decides what WOULD run in Node, - * and coverage reports it, but code generation does not change. */ + * The decision drives code generation: an island package or module is not + * compiled natively but loaded by the program's Node island. */ import { isAbsolute, join, resolve, dirname } from "node:path"; import type { ProjectConfig } from "../project-config.js"; From 9aaccac8dca7e6e97ae99ec38f0d02074a605a32 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:53:24 -0700 Subject: [PATCH 08/44] Detect the sloppy-only constructs a native build cannot express MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-scan markers name JavaScript whose semantics need the engine: a direct call of `eval` (it reads and writes the caller's scope), a function declaration in a block in sloppy code (Annex B.3.2 also binds the name as a `var` in the enclosing function), and `this` in a sloppy plain function (a plain call gets the global object). `with` now marks only sloppy code, where strict code is an early error Node refuses anyway. The `this` marker is conservative by design: a file cannot see its call sites, so every `this` in a sloppy plain function marks the module, except the shapes the file itself proves are receiver calls — a `new`-only constructor and an `X.prototype.name = function` method, neither of which is also called bare (decision S5). Markers narrow, never move, the native passes: inside a function body a block-level declaration is ordinary, the comma and `globalThis` spellings of `eval` stay indirect, and a file that does not parse as sloppy JavaScript keeps none of the sloppy markers, so a parse-negative test stays the compile refusal it is. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/node-islands/detect.test.ts | 80 +++++++++- packages/compiler/src/node-islands/detect.ts | 142 ++++++++++++++++-- packages/compiler/src/node-islands/rules.ts | 3 + packages/compiler/src/node-islands/scan.ts | 41 ++++- 4 files changed, 254 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/node-islands/detect.test.ts b/packages/compiler/src/node-islands/detect.test.ts index 2a21395da..72cbae9c7 100644 --- a/packages/compiler/src/node-islands/detect.test.ts +++ b/packages/compiler/src/node-islands/detect.test.ts @@ -13,7 +13,8 @@ function markers(source: string, ext = "cjs"): string[] { describe("R1 runtime markers", () => { test("R1.1 code from strings", () => { - expect(markers("eval(code);")).toEqual(["R1.1 code-from-string"]); + // A bare eval() is also a sloppy-mode construct (R2.4 direct-eval). + expect(markers("eval(code);")).toEqual(["R1.1 code-from-string", "R2.4 direct-eval"]); expect(markers("(0, eval)(code);")).toEqual(["R1.1 code-from-string"]); expect(markers("const f = new Function('a', 'return a');")).toEqual(["R1.1 code-from-string"]); expect(markers("const f = Function('a', body);")).toEqual(["R1.1 code-from-string"]); @@ -80,13 +81,16 @@ describe("R2 object-model markers", () => { expect(markers("const util = require('util'); util.inherits(A, B);")).toEqual([ "R2.1 constructor-function", ]); + // `this` in a plain function is also the conservative sloppy-this marker + // (decision S2): the file cannot see a call site in another module. expect(markers("function Child() { Parent.call(this, 1); }")).toEqual([ "R2.1 constructor-function", + "R2.4 sloppy-this", ]); expect(markers("class C {} const c = new C();")).toEqual([]); expect( markers("function wrap(fn) { return function () { return fn.apply(this, arguments); }; }"), - ).toEqual([]); + ).toEqual(["R2.4 sloppy-this"]); }); test("R2.2 prototype mutation", () => { @@ -133,6 +137,77 @@ describe("R2 object-model markers", () => { expect(markers("class C { m(a) { a = 2; return arguments[0]; } }")).toEqual([]); }); + test("R2.4 direct eval in sloppy mode", () => { + expect(markers("eval(code);")).toEqual(["R1.1 code-from-string", "R2.4 direct-eval"]); + expect(markers("function f() { return eval('x'); }")).toEqual([ + "R1.1 code-from-string", + "R2.4 direct-eval", + ]); + // Indirect, aliased and builtin-object calls read no local scope. + expect(markers("(0, eval)(code);")).toEqual(["R1.1 code-from-string"]); + // An aliased eval is not a call the scan recognizes as code-from-string. + expect(markers("const e = eval; e(code);")).toEqual([]); + expect(markers("globalThis.eval(code);")).toEqual(["R1.1 code-from-string"]); + expect(markers("function eval(code) { return code; } eval('x');")).toEqual([]); + // Strict code's direct eval cannot see the caller's scope: no marker. + expect(markers("'use strict'; eval(code);")).toEqual(["R1.1 code-from-string"]); + expect(markers("export function f() { return eval('x'); }", "mjs")).toEqual([ + "R1.1 code-from-string", + ]); + }); + + test("R2.4 Annex B block functions in sloppy mode", () => { + // A block-level function declaration also creates a var-like binding in + // the enclosing function (Annex B.3.2). + expect(markers("if (x) { function f() {} f(); }")).toEqual(["R2.4 annexb-block-function"]); + expect(markers("{ function f() {} }")).toEqual(["R2.4 annexb-block-function"]); + expect(markers("function g() { { function f() {} } }")).toEqual(["R2.4 annexb-block-function"]); + // A declaration in a function body or at the top level is ordinary, and + // a strict block declaration is block-scoped. + expect(markers("function f() {}")).toEqual([]); + expect(markers("function g() { function f() {} }")).toEqual([]); + expect(markers("'use strict'; if (x) { function f() {} }")).toEqual([]); + expect(markers("export function g() { if (x) { function f() {} } }", "mjs")).toEqual([]); + }); + + test("R2.4 'this' in a plain sloppy call (decision S2)", () => { + // A plain call's `this` is the global object in sloppy mode and + // undefined in strict mode: the file cannot see its call sites, so any + // plain function's `this` marks the module. + expect(markers("function f() { return this; }")).toEqual(["R2.4 sloppy-this"]); + expect(markers("const f = function () { return this; };")).toEqual(["R2.4 sloppy-this"]); + expect(markers("function f() { const g = () => this; return g(); }")).toEqual([ + "R2.4 sloppy-this", + ]); + expect(markers("function f() { return { m() { return this; } }; }")).toEqual([]); + // A receiver-carrying call, a class body, a class member initializer, + // top-level `this` (the exports object) and strict code carry no marker. + expect(markers("const o = { m() { return this; } }; o.m();")).toEqual([]); + // A property-assigned function expression is indistinguishable from a + // bare-call site in another module: conservative marking (S2). + expect(markers("const o = { m: function () { return this; } };")).toEqual(["R2.4 sloppy-this"]); + expect(markers("class C { m() { return this; } static s = this; }")).toEqual([]); + expect(markers("this.x = 1;")).toEqual([]); + expect(markers("'use strict'; function f() { return this; }")).toEqual([]); + expect(markers("export function f() { return this; }", "mjs")).toEqual([]); + }); + + test("R2.4 receiver-proven functions stay native (decision S5)", () => { + const sloppy = (source: string) => markers(source).filter((m) => m.startsWith("R2.4")); + // Native passes the receiver for both shapes: `new X()` and a + // `X.prototype.name` method. Marking them would move native passes. + expect(sloppy("function Ctor() { this.x = 1; } new Ctor();")).toEqual([]); + expect(sloppy("const Ctor = function () { this.x = 1; }; new Ctor();")).toEqual([]); + expect(sloppy("function M() {} M.prototype.m = function () { return this; };")).toEqual([]); + // The name is also called plainly, so the receiver is not proven. + expect(sloppy("function Ctor() { return this; } new Ctor(); Ctor();")).toEqual([ + "R2.4 sloppy-this", + ]); + // A callback's receiver belongs to its callee, which the file cannot + // see: conservative marking (S2). + expect(sloppy("function f() { return this; } [1].map(f);")).toEqual(["R2.4 sloppy-this"]); + }); + test("R2.5 builtin and global patching", () => { expect(markers("Array.prototype.last = function () {};")).toEqual(["R2.5 builtin-write"]); expect(markers("Promise = Bluebird;")).toEqual(["R2.5 builtin-write"]); @@ -156,6 +231,7 @@ test("markers come back in source order with their locations", () => { expect(found.map((m) => [m.rule, source.slice(m.loc.start, m.loc.end)])).toEqual([ ["R2.2", "Object.setPrototypeOf(a, b)"], ["R1.1", "eval(x)"], + ["R2.4", "eval(x)"], ]); }); diff --git a/packages/compiler/src/node-islands/detect.ts b/packages/compiler/src/node-islands/detect.ts index f612d58e5..e0c341bd5 100644 --- a/packages/compiler/src/node-islands/detect.ts +++ b/packages/compiler/src/node-islands/detect.ts @@ -9,8 +9,10 @@ * declare it at all, and a builtin module object when the file binds it to * require("") or imports it from one. Checker-dependent parts of * R2.3 (writes the declared type does not cover, freeze followed by writes) - * and R2.4 (assignments to undeclared identifiers, `this` as the global - * object) are left to the lowering. */ + * and R2.4 (assignments to undeclared identifiers) are left to the lowering; + * `this` in a plain call is marked conservatively (decision S2): a file + * cannot see its call sites, so every `this` in a sloppy plain function + * marks the module. */ import type { SrcLoc } from "../ir/ir.js"; import * as ts from "../frontend/ts7/adapter.js"; import { SUPPORTED_BUILTIN_MODULES } from "../frontend/builtin-modules.js"; @@ -236,6 +238,29 @@ function isFunctionBoundary(node: ts.Node): boolean { ); } +/** The name a plain function value is bound to in the same file + * (`function f`, `var f = function`), for the constructor refinement. */ +function boundFunctionName(fn: ts.FunctionDeclaration | ts.FunctionExpression): string | null { + if (ts.isFunctionDeclaration(fn)) return fn.name?.text ?? null; + const parent = fn.parent; + if (parent === undefined) return null; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text; + if (ts.isPropertyAssignment(parent)) return propertyName(parent.name); + return null; +} + +/** `X.prototype.name = function () {}`: a method assignment, whose every + * call through the same spelling has a receiver. */ +function isPrototypeFunctionAssignment(left: ts.PropertyAccessExpression, right: ts.Node): boolean { + let value = right; + while (ts.isParenthesizedExpression(value)) value = value.expression; + return ( + ts.isFunctionExpression(value) && + ts.isPropertyAccessExpression(left.expression) && + propertyName(left.expression) === "prototype" + ); +} + /** One module's pre-scan. */ class ModuleScanner { readonly markers: NodeIslandMarker[] = []; @@ -250,6 +275,15 @@ class ModuleScanner { private readonly builtinBindings = new Map(); /** Local names bound to a createRequire(...) result. */ private readonly createdRequires = new Set(); + /** Names the file constructs with `new`: a constructor's `this` is never + * the global object, so its `this` uses need no island (decision S5). */ + private readonly constructed = new Set(); + /** Names the file calls as a bare identifier, `f(...)`: a name used both + * ways is a constructor and a plain function, so it still marks. */ + private readonly bareCalled = new Set(); + /** Function expressions the file assigns to `X.prototype.name`: every + * same-file call of them is a receiver call (decision S5). */ + private readonly prototypeFunctions = new Set(); private readonly strictness = new Map(); private readonly moduleStrict: boolean; @@ -362,6 +396,17 @@ class ModuleScanner { this.bindName(node.name, node.initializer); } else if (ts.isParameter(node)) { this.bindName(node.name, undefined); + } else if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) { + this.constructed.add(node.expression.text); + } else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + this.bareCalled.add(node.expression.text); + } else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + isPrototypeFunctionAssignment(node.left, node.right) + ) { + this.prototypeFunctions.add(node.right); } else if (ts.isImportDeclaration(node)) { const spec = literalText(node.moduleSpecifier); const module = spec === null ? null : builtinModuleName(spec); @@ -427,6 +472,67 @@ class ModuleScanner { return BUILTIN_GLOBALS.has(name) && !this.declared.has(name); } + /** A function declaration whose parent is a block that is not a function + * body: Annex B.3.2's block-level declaration, which in sloppy code also + * creates a `var`-like binding in the enclosing function (DESIGN.md note 1 + * §3.1, construct 5). A declaration directly in a function body or at the + * top level of a script is ordinary. */ + private isAnnexBBlockFunction(fn: ts.FunctionDeclaration): boolean { + const parent = fn.parent; + if (parent === undefined || !ts.isBlock(parent)) return false; + const owner = parent.parent; + if (owner === undefined) return false; + // A function-like body, a class static block or a class member + // initializer block is not a bare block statement. + return (owner as ts.FunctionLikeDeclaration).body !== parent; + } + + /** Whether the file shows the function is only ever called with a + * receiver, so native's missing sloppy `this` cannot be observed: it is + * constructed with `new`, or it is a `X.prototype.name` method (decision + * S5 — the marker islands what native gets wrong, and native passes the + * receiver for both shapes). */ + private receiverSafe(fn: ts.FunctionDeclaration | ts.FunctionExpression): boolean { + if (this.prototypeFunctions.has(fn)) return true; + const name = boundFunctionName(fn); + return name !== null && this.constructed.has(name) && !this.bareCalled.has(name); + } + + /** `this` in a plain function call: a sloppy function that is not a + * method, constructor, accessor or class member, and is not an arrow + * function (whose `this` is its enclosing function's). Conservative + * (decision S2): a file-level scan cannot see cross-file call sites, so + * any `this` in such a function marks the module, except the shapes the + * file itself proves are receiver calls (decision S5). Top-level `this` + * is not this marker: it is the module's exports object, and the test262 + * profile's script-environment exclusion covers the script form. */ + private visitThis(node: ts.Node): void { + if (this.isStrict(node)) return; + for (let cur: ts.Node | undefined = node.parent; cur !== undefined; cur = cur.parent) { + // An arrow function has no `this` of its own: keep looking outward. + if (ts.isArrowFunction(cur)) continue; + if (ts.isFunctionDeclaration(cur) || ts.isFunctionExpression(cur)) { + if (this.receiverSafe(cur)) return; + this.add("R2.4", "sloppy-this", "'this' in a plain call in sloppy mode", node); + return; + } + // A method, constructor or accessor has a receiver; a class member + // initializer and a class static block have their own `this`; a + // property assignment's function is commonly a method too. + if ( + isFunctionBoundary(cur) || + ts.isClassDeclaration(cur) || + ts.isClassExpression(cur) || + ts.isClassStaticBlockDeclaration(cur) || + ts.isPropertyDeclaration(cur) || + ts.isPropertyAssignment(cur) + ) + return; + // Top-level `this` is the module's exports object, not a call site. + if (ts.isSourceFile(cur)) return; + } + } + private visit(node: ts.Node): void { if (ts.isCallExpression(node)) this.visitCall(node); else if (ts.isNewExpression(node)) this.visitNew(node); @@ -440,7 +546,9 @@ class ModuleScanner { ) this.visitWrite(node.operand, node, "write"); } else if (ts.isWithStatement(node)) { - this.add("R2.4", "with", "a 'with' statement", node); + // A `with` in strict code is an early error (Node refuses the file + // too), so only sloppy code carries the sloppy-semantics marker. + if (!this.isStrict(node)) this.add("R2.4", "with", "a 'with' statement", node); } else if (ts.isImportDeclaration(node)) { const spec = literalText(node.moduleSpecifier); if (spec !== null) this.visitSpecifier(spec, node); @@ -454,6 +562,8 @@ class ModuleScanner { const text = chainText(node); if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node)) this.add("R2.4", "sloppy-arguments", `'${text}'`, node); + } else if (node.kind === ts.SyntaxKind.ThisKeyword) { + this.visitThis(node); } else if (ts.isObjectLiteralExpression(node)) { for (const prop of node.properties.nodes) { if (!ts.isPropertyAssignment(prop)) continue; @@ -506,16 +616,23 @@ class ModuleScanner { // eval, Function let target = callee; while (ts.isParenthesizedExpression(target)) target = target.expression; + // A bare `eval(...)` is the intrinsic's DIRECT call: it reads and + // creates bindings in the caller's scope, and in sloppy mode creates + // globals (DESIGN.md note 1 §3.1). `globalThis.eval(...)`, the + // comma-operator idiom `(0, eval)(...)` and the other spellings are + // indirect: they observe no caller scope, so only R1.1. + const direct = ts.isIdentifier(target) && target.text === "eval" && !this.declared.has("eval"); if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.CommaToken) target = target.right; const text = chainText(target); if (text === null) return; - if ( - (text === "eval" && !this.declared.has("eval")) || - text === "globalThis.eval" || - text === "global.eval" || - text === "window.eval" - ) { + if (text === "eval" && !this.declared.has("eval")) { + this.add("R1.1", "code-from-string", "eval()", call); + if (direct && !this.isStrict(call)) + this.add("R2.4", "direct-eval", "a direct call of eval() in sloppy mode", call); + return; + } + if (text === "globalThis.eval" || text === "global.eval" || text === "window.eval") { this.add("R1.1", "code-from-string", "eval()", call); return; } @@ -789,6 +906,13 @@ class ModuleScanner { /** Observable `arguments` aliasing in a sloppy function: writes to * arguments[i], or to a parameter while `arguments` is read. */ private visitFunction(fn: ts.FunctionLikeDeclaration): void { + if (ts.isFunctionDeclaration(fn) && this.isAnnexBBlockFunction(fn) && !this.isStrict(fn)) + this.add( + "R2.4", + "annexb-block-function", + "a function declaration in a block in sloppy mode", + fn, + ); const body = fn.body; if (body === undefined || !ts.isBlock(body)) return; if (this.isStrict(body.statements.nodes[0] ?? body)) return; diff --git a/packages/compiler/src/node-islands/rules.ts b/packages/compiler/src/node-islands/rules.ts index 25f686314..b074ac5bc 100644 --- a/packages/compiler/src/node-islands/rules.ts +++ b/packages/compiler/src/node-islands/rules.ts @@ -69,6 +69,9 @@ const REASON_RULES: Record = { with: "R2.4", "sloppy-arguments": "R2.4", "sloppy-mode": "R2.4", + "direct-eval": "R2.4", + "annexb-block-function": "R2.4", + "sloppy-this": "R2.4", "builtin-write": "R2.5", proxy: "R2.5", }; diff --git a/packages/compiler/src/node-islands/scan.ts b/packages/compiler/src/node-islands/scan.ts index 0ac082e73..70d7355fc 100644 --- a/packages/compiler/src/node-islands/scan.ts +++ b/packages/compiler/src/node-islands/scan.ts @@ -94,6 +94,44 @@ function specifiersOf(program: ts.Program, sf: ts.SourceFile): { spec: string; n return out; } +/** The diagnostics of a construct that sloppy-mode Node accepts: the + * frontend compiles every file under the project's `strict` options (with + * `alwaysStrict`), so a `with`, an octal literal, or a strict-only reserved + * word reports here even though the file itself is a CommonJS script that + * Node runs sloppy. JavaScript grammar errors arrive in the bind pass + * (TS1101 among them), so both passes filter by this set. They cannot hide + * a real syntax error: they exist only for code the engine parses in sloppy + * mode. */ +const SLOPPY_LEGAL_CODES: ReadonlySet = new Set([ + 1100, // invalid use of a strict-mode reserved word + 1101, // 'with' statements are not allowed in strict mode + 1102, // 'delete' of an identifier in strict mode + 1121, // an octal literal + 1487, // an octal escape sequence + 1210, // a class body's strict-mode restriction + 1212, // a strict-mode reserved word + 1213, // a strict-mode reserved word in a class + 1214, // a strict-mode reserved word in a module + 1215, // invalid use of a strict-mode-only construct in a module +]); + +/** Whether the file parses as the sloppy JavaScript the island would run: + * a file with a genuine syntax or early error cannot run in an island as + * written (Node refuses it too, at load), so its pre-scan markers must not + * put it there — a parse-negative test stays the compile refusal it is + * (decision S5: the markers narrow, never move, the native passes). */ +function sloppyParsable(program: ts.Program, sf: ts.SourceFile): boolean { + try { + const sloppyLegal = (d: ts.Diagnostic): boolean => SLOPPY_LEGAL_CODES.has(d.code); + return ( + program.getSyntacticDiagnostics(sf).every(sloppyLegal) && + program.getBindDiagnostics(sf).every(sloppyLegal) + ); + } catch { + return true; + } +} + /** Scans `files`: markers for npm JavaScript (and for user JavaScript when * `userJs`), and package edges for every file when `edges` (a program with * no npm package has none to find). */ @@ -112,7 +150,8 @@ export function scanFiles( if (origin === "builtin" || origin === "shim") continue; const at = origin === "npm" ? packageDirOf(sf.fileName) : null; const js = isJsSourceFileName(sf.fileName); - const markers = js && (origin === "npm" || opts.userJs) ? scanModule(sf) : []; + let markers = js && (origin === "npm" || opts.userJs) ? scanModule(sf) : []; + if (markers.length > 0 && !sloppyParsable(program, sf)) markers = []; modules.push({ file: sf.fileName, origin, From 1a1b5bbce2a535ff98d84e7eb9c017a05bc00135 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:53:32 -0700 Subject: [PATCH 09/44] Island sloppy user modules automatically under --node-islands=auto Under the default `auto` mode a user JavaScript module whose pre-scan carries a sloppy-only construct no longer stays a refusal: it joins the island set, so the program runs it in Node at the position it is required (decision S3). The marked forms (`--node-island=`, `scriptc.nodeIslands.modules`) keep working unchanged, as does the existing lowering-driven recovery. The entry stays native: a program whose entry is an island would run the whole file in Node, which is a different feature and not this slice. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/executable/prepare.ts | 52 +++++++++++++++------ 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index fc6484438..2d1e30fbc 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -33,7 +33,11 @@ import { } from "../node-islands/policy.js"; import { buildNodeIslandReport } from "../node-islands/report.js"; import type { IslandModule, IslandPackage, NodeIslandSet } from "../node-islands/boundary.js"; -import { classifyDiagnostic, isIslandReasonCode } from "../node-islands/classify.js"; +import { + classifyDiagnostic, + classifyMarker, + isIslandReasonCode, +} from "../node-islands/classify.js"; import { packageDirOf, packageVersionOf } from "../node-islands/origin.js"; import { describe } from "../node-islands/summary.js"; import { resolveBareModule } from "../frontend/resolve.js"; @@ -347,10 +351,22 @@ function samePath(a: string, b: string): boolean { return a.replaceAll("\\", "/") === b.replaceAll("\\", "/"); } +/** The pre-scan reasons that make a user module a sloppy-semantics island + * by themselves (DESIGN.md note 1 §3.1): the constructs whose behavior + * needs the JavaScript engine and that a syntax scan decides exactly, plus + * the conservative `this` marker (decision S2). */ +const SLOPPY_REASONS: ReadonlySet = new Set([ + "with", + "direct-eval", + "annexb-block-function", + "sloppy-this", +]); + /** The program modules that run in Node islands, by file: modules marked - * with `--node-island=` or `scriptc.nodeIslands.modules`. The entry - * stays native: a program whose entry is an island runs the whole file in - * Node, which is a different feature. */ + * with `--node-island=` or `scriptc.nodeIslands.modules`, and (under + * `--node-islands=auto`) user JavaScript the pre-scan found a sloppy-only + * construct in (decision S3). The entry stays native: a program whose entry + * is an island runs the whole file in Node, which is a different feature. */ function moduleIslands( entryPath: string, policy: NodeIslandPolicy, @@ -362,15 +378,20 @@ function moduleIslands( if (!isJsSourceFileName(m.file)) continue; if (sameFile(m.file, entryPath)) continue; const marked = isMarkedUserModule(policy, m.file); - if (marked === null) continue; + const sloppy = m.markers.filter((marker) => SLOPPY_REASONS.has(marker.reason)); + if (marked === null && (policy.mode !== "auto" || sloppy.length === 0)) continue; + const first = sloppy[0]; + const reason = first === undefined ? null : classifyMarker(first); modules.set(m.file, { file: m.file, - class: "marked", + class: reason === null ? "marked" : reason.class, reason: - marked === "cli" - ? "marked with --node-island" - : 'marked "node" in scriptc.nodeIslands.modules', - decision: "marked", + reason === null + ? marked === "cli" + ? "marked with --node-island" + : 'marked "node" in scriptc.nodeIslands.modules' + : describe(reason), + decision: marked === null ? "auto" : "marked", }); } return modules; @@ -378,9 +399,10 @@ function moduleIslands( /** The packages and modules a build runs in Node islands, or null for none: * under `--node-islands=auto` (the default) every npm package the native - * pipeline refused, plus the packages and modules marked `node`; under - * `explicit` only the marked ones; under `off`, none (refusals stay - * SC2013). Each island is reported once as an SC8001 note. */ + * pipeline refused plus the user modules the pre-scan found a sloppy + * construct in, and packages marked `node`; under `explicit` only the + * marked ones; under `off`, none (refusals stay SC2013). Each island is + * reported once as an SC8001 note. */ function decideNodeIslands( entryPath: string, opts: CompileRequestOptions, @@ -400,7 +422,9 @@ function decideNodeIslands( : [], ); for (const name of lowerIslands.keys()) refused.add(name); - const scan = fe.nodeIslandScan({ userJs: false }); + // The pre-scan's user-JavaScript markers exist only where a build may act + // on them. + const scan = fe.nodeIslandScan({ userJs: policy.mode === "auto" }); const modules = moduleIslands(entryPath, policy, scan); // Modules the lowering refused keep their first reason (the reload no // longer compiles them). From 273c9e550637001296002ec1202ab87517dc6d90 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:54:08 -0700 Subject: [PATCH 10/44] Prove the sloppy island semantics against Node in the corpus Four programs whose user module needs the engine, each run under Node and as a compiled binary with byte-identical stdout, stderr and exit status: `with` (including the shadowed and nested lookups), Annex B.3.2 block functions (before, after and inside a loop), direct `eval` (reading and writing the caller's scope, and the `var` it declares landing in the caller's function), and `this` in a plain call (plain, `call`, `apply`, bound, and an arrow function's enclosing `this`). Each entry stays native and requires its module, so the programs exercise the module island path end to end rather than a whole-file island. Co-Authored-By: Claude Opus 5.5 --- .../8423-sloppy-with-island/main.js.json | 107 ++++++++++++++++++ .../main.js.json | 7 ++ .../main.js.json | 7 ++ .../8426-sloppy-this-island/main.js.json | 7 ++ .../corpus/8423-sloppy-with-island/lookup.js | 32 ++++++ tests/corpus/8423-sloppy-with-island/main.js | 10 ++ .../8423-sloppy-with-island/package.json | 3 + .../annexb.js | 47 ++++++++ .../main.js | 9 ++ .../package.json | 3 + .../8425-sloppy-direct-eval-island/main.js | 8 ++ .../package.json | 3 + .../8425-sloppy-direct-eval-island/scopes.js | 42 +++++++ tests/corpus/8426-sloppy-this-island/main.js | 9 ++ .../8426-sloppy-this-island/package.json | 3 + .../8426-sloppy-this-island/receivers.js | 29 +++++ .../8423-sloppy-with-island/main.js.json | 1 + .../main.js.json | 1 + .../main.js.json | 1 + .../8426-sloppy-this-island/main.js.json | 1 + 20 files changed, 330 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-sloppy-annexb-block-function-island/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-sloppy-direct-eval-island/main.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-sloppy-this-island/main.js.json create mode 100644 tests/corpus/8423-sloppy-with-island/lookup.js create mode 100644 tests/corpus/8423-sloppy-with-island/main.js create mode 100644 tests/corpus/8423-sloppy-with-island/package.json create mode 100644 tests/corpus/8424-sloppy-annexb-block-function-island/annexb.js create mode 100644 tests/corpus/8424-sloppy-annexb-block-function-island/main.js create mode 100644 tests/corpus/8424-sloppy-annexb-block-function-island/package.json create mode 100644 tests/corpus/8425-sloppy-direct-eval-island/main.js create mode 100644 tests/corpus/8425-sloppy-direct-eval-island/package.json create mode 100644 tests/corpus/8425-sloppy-direct-eval-island/scopes.js create mode 100644 tests/corpus/8426-sloppy-this-island/main.js create mode 100644 tests/corpus/8426-sloppy-this-island/package.json create mode 100644 tests/corpus/8426-sloppy-this-island/receivers.js create mode 100644 tests/harness/corpus-costs/8423-sloppy-with-island/main.js.json create mode 100644 tests/harness/corpus-costs/8424-sloppy-annexb-block-function-island/main.js.json create mode 100644 tests/harness/corpus-costs/8425-sloppy-direct-eval-island/main.js.json create mode 100644 tests/harness/corpus-costs/8426-sloppy-this-island/main.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json new file mode 100644 index 000000000..c6b9ad9a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json @@ -0,0 +1,107 @@ +{ + "order": [ + "/tests/corpus/8423-sloppy-with-island/lookup.js", + "/tests/corpus/8423-sloppy-with-island/main.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "'with' statements are not allowed in strict mode.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 194, + "end": 198 + } + }, + { + "code": "SC0001", + "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 194, + "end": 207 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'last'.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 252, + "end": 256 + } + }, + { + "code": "SC0001", + "message": "'with' statements are not allowed in strict mode.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 295, + "end": 299 + } + }, + { + "code": "SC0001", + "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 295, + "end": 308 + } + }, + { + "code": "SC0001", + "message": "'with' statements are not allowed in strict mode.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 422, + "end": 426 + } + }, + { + "code": "SC0001", + "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 422, + "end": 447 + } + }, + { + "code": "SC0001", + "message": "'with' statements are not allowed in strict mode.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 528, + "end": 532 + } + }, + { + "code": "SC0001", + "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 528, + "end": 541 + } + }, + { + "code": "SC0001", + "message": "'with' statements are not allowed in strict mode.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 548, + "end": 552 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'inner'.", + "loc": { + "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "start": 576, + "end": 581 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-sloppy-annexb-block-function-island/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-sloppy-annexb-block-function-island/main.js.json new file mode 100644 index 000000000..c10f1a5fe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-sloppy-annexb-block-function-island/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8424-sloppy-annexb-block-function-island/annexb.js", + "/tests/corpus/8424-sloppy-annexb-block-function-island/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-sloppy-direct-eval-island/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-sloppy-direct-eval-island/main.js.json new file mode 100644 index 000000000..fc8715ea9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-sloppy-direct-eval-island/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8425-sloppy-direct-eval-island/scopes.js", + "/tests/corpus/8425-sloppy-direct-eval-island/main.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-sloppy-this-island/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-sloppy-this-island/main.js.json new file mode 100644 index 000000000..ea5f8598e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8426-sloppy-this-island/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8426-sloppy-this-island/receivers.js", + "/tests/corpus/8426-sloppy-this-island/main.js" + ], + "diags": [] +} diff --git a/tests/corpus/8423-sloppy-with-island/lookup.js b/tests/corpus/8423-sloppy-with-island/lookup.js new file mode 100644 index 000000000..2ea0f4239 --- /dev/null +++ b/tests/corpus/8423-sloppy-with-island/lookup.js @@ -0,0 +1,32 @@ +// A `with` resolves names against the record it is given, then falls back +// to the enclosing scope when the record has no such property. +var greeting = "hello"; + +function describe(record) { + with (record) { + return greeting + " " + first + " " + last; + } +} + +function fallback(record) { + with (record) { + return typeof first === "string" ? first : "nobody"; + } +} + +function shadowed() { + var first = "outer"; + with ({ first: "inner" }) { + return first + "/" + arguments.length; + } +} + +function nested(record) { + with (record) { + with (outer) { + return inner; + } + } +} + +module.exports = { describe, fallback, shadowed, nested }; diff --git a/tests/corpus/8423-sloppy-with-island/main.js b/tests/corpus/8423-sloppy-with-island/main.js new file mode 100644 index 000000000..96038479e --- /dev/null +++ b/tests/corpus/8423-sloppy-with-island/main.js @@ -0,0 +1,10 @@ +// @requires-libnode +// A sloppy CommonJS module that uses `with` runs in a Node island +// (DESIGN.md note 1 §3.2, decision S3): the native entry requires it, the +// island loads through Node at that position, and the program's stdout +// matches Node's byte for byte. +const lookup = require("./lookup.js"); + +console.log(lookup.describe({ first: "Ada", last: "Lovelace" })); +console.log(lookup.fallback({}), lookup.fallback({ first: "Grace" })); +console.log(lookup.shadowed(), lookup.nested({ outer: { inner: "deep" } })); diff --git a/tests/corpus/8423-sloppy-with-island/package.json b/tests/corpus/8423-sloppy-with-island/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8423-sloppy-with-island/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8424-sloppy-annexb-block-function-island/annexb.js b/tests/corpus/8424-sloppy-annexb-block-function-island/annexb.js new file mode 100644 index 000000000..6121eb5f4 --- /dev/null +++ b/tests/corpus/8424-sloppy-annexb-block-function-island/annexb.js @@ -0,0 +1,47 @@ +function reached(flag) { + if (flag) { + function inner() { + return "block"; + } + return inner(); + } + return typeof inner; +} + +function beforeBlock() { + var seen = typeof helper; + { + function helper() { + return "helped"; + } + } + return seen + "/" + helper(); +} + +function afterBlock() { + if (true) { + function helper() { + return "first"; + } + } else { + function helper() { + return "second"; + } + } + return helper(); +} + +function looped() { + var out = []; + for (var i = 0; i < 2; i++) { + if (i === 1) { + function last() { + return "last " + i; + } + } + out.push(typeof last); + } + return out.join(","); +} + +module.exports = { reached, beforeBlock, afterBlock, looped }; diff --git a/tests/corpus/8424-sloppy-annexb-block-function-island/main.js b/tests/corpus/8424-sloppy-annexb-block-function-island/main.js new file mode 100644 index 000000000..fd11d7850 --- /dev/null +++ b/tests/corpus/8424-sloppy-annexb-block-function-island/main.js @@ -0,0 +1,9 @@ +// @requires-libnode +// Annex B.3.2 in a sloppy Annex B block function: a block-level function +// declaration also creates a var-like binding in the enclosing function, so +// the name is visible before the block runs and after it. +const annexb = require("./annexb.js"); + +console.log(annexb.reached(true), annexb.reached(false)); +console.log(annexb.beforeBlock(), annexb.afterBlock()); +console.log(annexb.looped()); diff --git a/tests/corpus/8424-sloppy-annexb-block-function-island/package.json b/tests/corpus/8424-sloppy-annexb-block-function-island/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8424-sloppy-annexb-block-function-island/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8425-sloppy-direct-eval-island/main.js b/tests/corpus/8425-sloppy-direct-eval-island/main.js new file mode 100644 index 000000000..c3a1cd8d6 --- /dev/null +++ b/tests/corpus/8425-sloppy-direct-eval-island/main.js @@ -0,0 +1,8 @@ +// @requires-libnode +// A sloppy direct eval sees and writes the caller's scope, and the `var` +// it declares lands in the caller's function scope. +const scopes = require("./scopes.js"); + +console.log(scopes.readsLocal(), scopes.writesLocal()); +console.log(scopes.declaresInFunction(), scopes.declaresAtTopLevel()); +console.log(scopes.seesArgument("arg"), scopes.repeated()); diff --git a/tests/corpus/8425-sloppy-direct-eval-island/package.json b/tests/corpus/8425-sloppy-direct-eval-island/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8425-sloppy-direct-eval-island/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8425-sloppy-direct-eval-island/scopes.js b/tests/corpus/8425-sloppy-direct-eval-island/scopes.js new file mode 100644 index 000000000..1f41f7275 --- /dev/null +++ b/tests/corpus/8425-sloppy-direct-eval-island/scopes.js @@ -0,0 +1,42 @@ +function readsLocal() { + var local = "seen"; + return eval("local"); +} + +function writesLocal() { + var local = 41; + eval("local = local + 1"); + return local; +} + +function declaresInFunction() { + eval('var fromEval = "declared";'); + return typeof fromEval + "/" + fromEval; +} + +var globalName = "module scope"; + +function declaresAtTopLevel() { + return eval("globalName"); +} + +function seesArgument(value) { + return eval("value + '!'"); +} + +function repeated() { + var total = 0; + for (var i = 1; i <= 3; i++) { + eval("total = total + i"); + } + return total; +} + +module.exports = { + readsLocal, + writesLocal, + declaresInFunction, + declaresAtTopLevel, + seesArgument, + repeated, +}; diff --git a/tests/corpus/8426-sloppy-this-island/main.js b/tests/corpus/8426-sloppy-this-island/main.js new file mode 100644 index 000000000..4967bd23d --- /dev/null +++ b/tests/corpus/8426-sloppy-this-island/main.js @@ -0,0 +1,9 @@ +// @requires-libnode +// In a sloppy plain call `this` is the global object, never undefined: a +// user module that observes it runs in a Node island (decision S2's +// conservative marker islandizes the module, the engine decides the value). +// The functions are called through destructured bindings, so every call +// site here is plain. +const { isGlobal, plain, viaCall, viaApply, bound, arrow } = require("./receivers.js"); + +console.log(isGlobal(), plain(), viaCall(), viaApply(), bound(), arrow()); diff --git a/tests/corpus/8426-sloppy-this-island/package.json b/tests/corpus/8426-sloppy-this-island/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/corpus/8426-sloppy-this-island/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/corpus/8426-sloppy-this-island/receivers.js b/tests/corpus/8426-sloppy-this-island/receivers.js new file mode 100644 index 000000000..4b718bafd --- /dev/null +++ b/tests/corpus/8426-sloppy-this-island/receivers.js @@ -0,0 +1,29 @@ +function isGlobal() { + return this === globalThis; +} + +function plain() { + return this === globalThis ? "global" : "other"; +} + +// An explicit receiver is that receiver, never the global object. +function viaCall() { + return isGlobal.call({ label: "receiver" }); +} + +function viaApply() { + return isGlobal.apply({ label: "receiver" }, []); +} + +// A bound undefined receiver is still the global object in sloppy mode. +function bound() { + return isGlobal.bind(undefined)(); +} + +// An arrow has no `this` of its own: it sees the sloppy function's. +function arrow() { + var see = () => this; + return see() === globalThis; +} + +module.exports = { isGlobal, plain, viaCall, viaApply, bound, arrow }; diff --git a/tests/harness/corpus-costs/8423-sloppy-with-island/main.js.json b/tests/harness/corpus-costs/8423-sloppy-with-island/main.js.json new file mode 100644 index 000000000..5adfa8964 --- /dev/null +++ b/tests/harness/corpus-costs/8423-sloppy-with-island/main.js.json @@ -0,0 +1 @@ +{ "plain": 2620, "san": 4900 } diff --git a/tests/harness/corpus-costs/8424-sloppy-annexb-block-function-island/main.js.json b/tests/harness/corpus-costs/8424-sloppy-annexb-block-function-island/main.js.json new file mode 100644 index 000000000..224d7a7b0 --- /dev/null +++ b/tests/harness/corpus-costs/8424-sloppy-annexb-block-function-island/main.js.json @@ -0,0 +1 @@ +{ "plain": 1607, "san": 1999 } diff --git a/tests/harness/corpus-costs/8425-sloppy-direct-eval-island/main.js.json b/tests/harness/corpus-costs/8425-sloppy-direct-eval-island/main.js.json new file mode 100644 index 000000000..50ce59ee0 --- /dev/null +++ b/tests/harness/corpus-costs/8425-sloppy-direct-eval-island/main.js.json @@ -0,0 +1 @@ +{ "plain": 1366, "san": 2024 } diff --git a/tests/harness/corpus-costs/8426-sloppy-this-island/main.js.json b/tests/harness/corpus-costs/8426-sloppy-this-island/main.js.json new file mode 100644 index 000000000..244011af6 --- /dev/null +++ b/tests/harness/corpus-costs/8426-sloppy-this-island/main.js.json @@ -0,0 +1 @@ +{ "plain": 1411, "san": 1828 } From 23bb419722d3dc9740ae93c6c611c4ade81c8085 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:01:31 -0700 Subject: [PATCH 11/44] Report Test262 island outcomes as their own status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile gets an `--islands` lane (decision S4). It lifts exactly the five host exclusions a Node island can express — eval, Function, arguments, script-environment, globalThis — on sloppy variants only; harness limits, the assertion surface and `$262` stay excluded, because the adapter still runs inside the island. An island variant runs as a tiny native entry that imports the adapted test, so the pre-scan can island the test module. When the build reports that module in its islands, the outcome is `island-pass` or `island-fail`: Node proved the semantics, so it is never counted as a native pass and an island failure of a lifted variant is not a native regression. The report records `islands` and `liftedVariants`, `--list` marks lifted cases, and the harness exports each assertion helper so the island can rebuild `assert` in JavaScript (a native function crosses the bridge as a callable without its namespace members). `--filter` is now repeatable, so one run can select a measured set of files. Co-Authored-By: Claude Opus 5.5 --- tests/harness/test262-runner.test.ts | 54 +++++++++++++ tests/test262/compile-worker.ts | 5 +- tests/test262/execute.ts | 71 ++++++++++++++--- tests/test262/harness.ts | 10 +++ tests/test262/run.ts | 62 +++++++++++---- tests/test262/support.mjs | 110 ++++++++++++++++++++++++--- 6 files changed, 272 insertions(+), 40 deletions(-) diff --git a/tests/harness/test262-runner.test.ts b/tests/harness/test262-runner.test.ts index 3873d1c34..46a0b5fa0 100644 --- a/tests/harness/test262-runner.test.ts +++ b/tests/harness/test262-runner.test.ts @@ -6,6 +6,8 @@ import { matchesParseNegative, metadata, prepare, + prepareIsland, + scriptEnvironment, summarize, variants, } from "../test262/support.mjs"; @@ -78,6 +80,58 @@ test("unsupported execution requirements and assertion reflection remain exclusi expect(exclusion(asyncText, metadata(asyncText), "strict")).toBeUndefined(); }); +test("the island lane lifts only the five host exclusions, on sloppy variants", () => { + const globalRead = source( + "description: global", + "assert.sameValue(typeof globalThis, 'object');", + ); + expect(exclusion(globalRead, metadata(globalRead), "sloppy")).toBe("host:globalThis"); + expect(exclusion(globalRead, metadata(globalRead), "sloppy", true)).toBeUndefined(); + // Strict variants stay in the native profile: the lane adds none. + expect(exclusion(globalRead, metadata(globalRead), "strict", true)).toBe("host:globalThis"); + for (const [description, body] of [ + ["eval", "assert.sameValue(eval('1 + 1'), 2);"], + ["Function", "assert.sameValue(Function('return 1')(), 1);"], + ["arguments", "function f() { return arguments; } assert.sameValue(f(1).length, 1);"], + ["script this", "assert.sameValue(this, undefined);"], + ]) { + const text = source(`description: ${description}`, body!); + expect(exclusion(text, metadata(text), "sloppy"), body).toBeTypeOf("string"); + expect(exclusion(text, metadata(text), "sloppy", true), body).toBeUndefined(); + } + // Harness limits and `$262` stay excluded even when the file also uses a + // lifted construct, and a CommonJS-specific name is not a host reason. + const helper = source("includes: [propertyHelper.js]", "verifyWritable({}, 'x'); eval('1');"); + expect(exclusion(helper, metadata(helper), "sloppy", true)).toBe( + "harness:propertyHelper-surface", + ); + const commonJsGlobal = source( + "description: CommonJS global", + "assert.sameValue(module.exports, {});", + ); + expect(exclusion(commonJsGlobal, metadata(commonJsGlobal), "sloppy", true)).toBe("host:module"); +}); + +test("a lifted script-environment variant keeps the script shape inside its island", () => { + const script = source("description: script this", "this.value = 1;\nassert.sameValue(value, 1);"); + expect(scriptEnvironment(script)).toBe(true); + const text = prepareIsland(script, false, "MARK", [], true); + // The harness is installed on the global object, and the module only carries + // a dead copy of the body so the pre-scan still islands the module. + expect(text).toContain("globalThis.assert = assert;"); + expect(text).toContain("globalThis.Test262Error = Test262Error;"); + expect(text).toContain('const { readFileSync } = require("node:fs");'); + expect(text).toContain('const vm = require("node:vm");'); + expect(text).toContain("vm.runInThisContext(text.slice(from + begin.length, to)"); + expect(text).toContain("if (false) { (function () {"); + expect(text).toContain("this.value = 1;"); + // The ordinary island adaptation still runs the body as module code. + expect(prepareIsland(script, false, "MARK", [])).not.toContain("vm.runInThisContext"); + // `host:script-environment` also names ESM syntax, which cannot be replayed + // as a script. + expect(scriptEnvironment("export default 1;")).toBe(false); +}); + test("the property helper only admits direct inline descriptor checks", () => { for (const body of [ "verifyProperty({}, 'x', { value: 1, configurable: false });", diff --git a/tests/test262/compile-worker.ts b/tests/test262/compile-worker.ts index 79652d867..523daa6c8 100644 --- a/tests/test262/compile-worker.ts +++ b/tests/test262/compile-worker.ts @@ -10,9 +10,12 @@ async function main(): Promise { outPath: request.binary, sanitize: request.sanitize, ...(request.backend === "default" ? {} : { backend: request.backend }), + // The native lane keeps the whole program strict; the island lane lets + // the pre-scan island sloppy user modules (decision S3). + nodeIslands: { mode: request.islands ? "auto" : "off" }, }); writeFileSync(request.result, JSON.stringify(result.ok - ? { ok: true, backend: result.backend } + ? { ok: true, backend: result.backend, islands: result.islands ?? null } : { ok: false, diagnostics: result.diagnostics })); } diff --git a/tests/test262/execute.ts b/tests/test262/execute.ts index 2ae94454d..bfaf1dcd0 100644 --- a/tests/test262/execute.ts +++ b/tests/test262/execute.ts @@ -1,17 +1,20 @@ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { assertThrowsSource, compileFailureStatus, completion, harnessSource, prepare } from "./support.mjs"; +import { assertThrowsSource, compileFailureStatus, completion, harnessSource, prepare, prepareIsland, scriptEnvironment } from "./support.mjs"; export interface Outcome { status: string; reason?: string; phase?: string; backend?: string; + /** The entry module the compiler put in a Node island (decision S4): the + * result is then `island-pass`/`island-fail`, never a native pass. */ + island?: true; stdout?: string; stderr?: string; diagnostics?: unknown[]; @@ -60,6 +63,17 @@ export function boundedRun(command: string, args: string[], timeoutMs: number): }); } +/** Whether two spellings name the same file (macOS resolves /var through + * /private/var, so the compiler may report the longer one). */ +function sameFile(a: string, b: string): boolean { + if (a === b) return true; + try { + return realpathSync(a) === realpathSync(b); + } catch { + return false; + } +} + export async function runSource(source: string, options: { backend?: "default" | "llvm"; asyncTest?: boolean; @@ -69,23 +83,48 @@ export async function runSource(source: string, options: { compileTimeoutMs?: number; runtimeTimeoutMs?: number; keep?: boolean; + /** Measure what an island can measure (decision S4): sloppy variants only, + * with the entry a tiny native module importing the adapted test, so the + * pre-scan can island it. The entry module itself always stays native. */ + islands?: boolean; + /** The exclusion the island lane lifted for this variant, when it lifted + * one. `host:script-environment` selects the script adaptation below. */ + liftedReason?: string; } = {}): Promise { // Keep sources OUTSIDE node_modules: scriptc intentionally treats imports // under that directory as package code, with different compilation rules. const workDir = mkdtempSync(join(tmpdir(), "scriptc-test262-")); - const entry = join(workDir, options.variant === "sloppy" ? "main.cjs" : "main.js"); + // The island lane keeps `main.cjs` as the adapted test module and adds a + // native entry that imports it: prepare() and the parse-negative matching + // both name the test module, and an island entry is not a program shape. + const islandLane = options.islands === true && options.variant === "sloppy"; + const entry = join(workDir, islandLane ? "entry.ts" : options.variant === "sloppy" ? "main.cjs" : "main.js"); + const testModule = islandLane ? join(workDir, "main.cjs") : entry; const binary = join(workDir, process.platform === "win32" ? "program.exe" : "program"); const resultFile = join(workDir, "compile.json"); const requestFile = join(workDir, "request.json"); let phase = "compile"; try { const marker = `${completion}:${randomBytes(16).toString("hex")}`; - writeFileSync(entry, prepare(source, options.asyncTest ?? false, marker, options.variant, options.includes)); + // A lifted script-environment variant keeps the script shape: top-level + // `this` and `var` must be global, so the island replays the body as a + // script instead of loading it as a CommonJS module. + const script = islandLane && !(options.asyncTest ?? false) && + options.liftedReason === "host:script-environment" && scriptEnvironment(source); + const text = islandLane + ? prepareIsland(source, options.asyncTest ?? false, marker, options.includes, script) + : prepare(source, options.asyncTest ?? false, marker, options.variant, options.includes); + writeFileSync(testModule, text); + // Evaluate the harness first: its shared bindings must be initialized + // before the island requires them, or the loader has to hold their + // temporal dead zone through the island's own require (a sync cycle). + if (islandLane) writeFileSync(entry, 'import "./harness.ts";\nimport "./main.cjs";\n'); writeFileSync(join(workDir, "harness.ts"), harnessSource.replace(completion, marker)); writeFileSync(join(workDir, "assert-throws.js"), assertThrowsSource); writeFileSync(requestFile, JSON.stringify({ entry, binary, workDir, result: resultFile, backend: options.backend ?? "default", sanitize: options.sanitize ?? false, + islands: islandLane, })); const compiled = await boundedRun(process.execPath, [ "--import", "tsx", fileURLToPath(new URL("./compile-worker.ts", import.meta.url)), requestFile, @@ -100,32 +139,40 @@ export async function runSource(source: string, options: { if (!build.ok) { return finish({ status: compileFailureStatus(build.diagnostics), phase, diagnostics: build.diagnostics }); } + // The build reports the modules it ran in islands, cache hits included. + // A variant whose test module is one of them reports its own outcome: + // the verdict is Node's, so it can never count as a native pass. + const island = islandLane && Array.isArray(build.islands?.modules) && + build.islands.modules.some((file) => sameFile(file, testModule)); phase = "runtime"; const result = await boundedRun(binary, [], options.runtimeTimeoutMs ?? 10_000); - const common = { backend: build.backend }; - if (result.timeout) return finish({ ...common, status: "timeout", phase }); - if (result.signal) return finish({ ...common, status: "crash", phase, reason: result.signal, stderr: result.stderr }); + const common = { backend: build.backend, ...(island ? { island: true as const } : {}) }; + const report = (outcome: Outcome): Outcome => island + ? { ...outcome, status: outcome.status === "pass" ? "island-pass" : "island-fail" } + : outcome; + if (result.timeout) return finish(report({ ...common, status: "timeout", phase })); + if (result.signal) return finish(report({ ...common, status: "crash", phase, reason: result.signal, stderr: result.stderr })); const stderr = options.sanitize ? result.stderr.replace(/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext.*\n/gm, "") : result.stderr; if (result.code === 86 && stderr === "SCRIPTC_TEST262_HARNESS: reference equality requires an identity-preserving adapter\n") { - return finish({ ...common, status: "harness-refusal", phase, reason: "reference-assertion", stderr }); + return finish(report({ ...common, status: "harness-refusal", phase, reason: "reference-assertion", stderr })); } if (result.code === 86 && stderr === "SCRIPTC_TEST262_HARNESS: assert.throws needs an Error instance\n") { - return finish({ ...common, status: "harness-refusal", phase, reason: "non-error-assertion", stderr }); + return finish(report({ ...common, status: "harness-refusal", phase, reason: "non-error-assertion", stderr })); } if (result.code === 0 && !result.overflow && stderr === "" && result.stdout === `${marker}\n`) { - return finish({ ...common, status: "pass" }); + return finish(report({ ...common, status: "pass" })); } // Require the entire diagnostic, so a sanitizer report following a known // refusal cannot be hidden by the regression expectation. const fence = /^Uncaught Error: [^\n]+\[(SC\d{4}) at ([^\n]+):\d+\]\n$/.exec(stderr); - return finish({ + return finish(report({ ...common, status: fence ? (fence[2]!.endsWith("/harness.ts") ? "harness-refusal" : "runtime-refusal") : "fail", phase, reason: fence?.[1] ?? `exit ${result.code}; completion marker required`, stdout: result.stdout, stderr, - }); + })); } catch (error) { return { status: "runner-error", phase, reason: String(error), ...(options.keep ? { workDir } : {}) }; } finally { diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index 40e09734a..ab47fc96f 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -119,3 +119,13 @@ export namespace assert { if (!matches) throw new Test262Error(text(message, "Expected matching array contents")); } } + +// The island lane (decision S4) assembles `assert` in JavaScript: a native +// function crosses the bridge as a callable, but the namespace members +// attached to it do not (they are properties of the native function object). +// Test262's `assert` must be callable and carry the helpers, so each helper +// is exported on its own and the island wrapper builds the object. +export const sameValue = assert.sameValue; +export const notSameValue = assert.notSameValue; +export const assertThrows = assert.throws; +export const compareArray = assert.compareArray; diff --git a/tests/test262/run.ts b/tests/test262/run.ts index 2ad02100b..74a826030 100644 --- a/tests/test262/run.ts +++ b/tests/test262/run.ts @@ -10,16 +10,17 @@ import { async function main(): Promise { const { values } = parseArgs({ options: { - root: { type: "string" }, filter: { type: "string" }, limit: { type: "string" }, + root: { type: "string" }, filter: { type: "string", multiple: true }, limit: { type: "string" }, report: { type: "string" }, journal: { type: "string" }, workers: { type: "string" }, backend: { type: "string", default: "default" }, "compile-timeout": { type: "string" }, "runtime-timeout": { type: "string" }, keep: { type: "boolean" }, list: { type: "boolean" }, help: { type: "boolean" }, + islands: { type: "boolean" }, } }); if (values.help) { console.log(`Static scriptc Test262 profile (dynamic engine disabled) pnpm test:test262 run the vendored regression profile pnpm test:test262 --root PATH survey a complete pinned snapshot - --filter TEXT select test paths containing TEXT + --filter TEXT select test paths containing TEXT (repeatable) --limit N first N selected test files --workers N concurrent compiles (default 2) --backend default|llvm LLVM is the production backend @@ -28,9 +29,16 @@ async function main(): Promise { --compile-timeout MS --runtime-timeout MS bound each compiler/program process --list list variants and exclusions without compiling --keep retain generated sources and native artifacts + --islands measure sloppy variants an island can run SCRIPTC_SAN=1 enables sanitizers; SCRIPTC_TEST_SHARD=i/n partitions variants. Adapted strict and sloppy scripts and source-matched syntax negatives can pass. -Other variants remain visible as exclusions.`); +Other variants remain visible as exclusions. + +--islands lifts exactly the five host exclusions a Node island can express +(eval, Function, arguments, script-environment, globalThis) on sloppy +variants. A variant whose test module the compiler islands reports +island-pass/island-fail: Node proved the semantics, so it is not a native +pass and an island failure of such a variant is not a native regression.`); return; } function positive(value: string | undefined, fallback: number, name: string): number { @@ -50,29 +58,44 @@ Other variants remain visible as exclusions.`); if (snapshotDigest(root) !== pin.snapshotSha256) throw new Error("Test262 snapshot differs from the pinned revision"); } else verifyVendor(); const allPaths: string[] = values.root ? testPaths(root) : pin.tests; - const selected = allPaths.filter((path) => !values.filter || path.includes(values.filter)).slice(0, limit); + const filters = values.filter ?? []; + const selected = allPaths + .filter((path) => filters.length === 0 || filters.some((filter) => path.includes(filter))) + .slice(0, limit); if (!selected.length) throw new Error("no Test262 files matched the selection"); + const islands = values.islands === true; const cases = selected.flatMap((path) => { const source = readFileSync(join(root, path), "utf8"); const meta = metadata(source, path); - return variants(meta).map((variant: string) => ({ - id: `${path}#${variant}`, path, variant, source, features: meta.features, includes: meta.includes, negative: meta.negative, - asyncTest: meta.flags.includes("async"), - exclusion: exclusion(source, meta, variant), - })); + return variants(meta).map((variant: string) => { + const exclusionReason = exclusion(source, meta, variant); + // Only the island lane adds variants; a lifted variant is newly + // measured, never a native result that could regress (decision S4). + const islandExclusion = islands ? exclusion(source, meta, variant, true) : exclusionReason; + return { + id: `${path}#${variant}`, path, variant, source, features: meta.features, includes: meta.includes, negative: meta.negative, + asyncTest: meta.flags.includes("async"), + exclusion: islandExclusion, + lifted: islandExclusion === undefined && exclusionReason !== undefined, + liftedReason: islandExclusion === undefined ? exclusionReason : undefined, + }; + }); }); const shard = shardSelect(cases, (item) => item.id); if (values.list) { - for (const item of shard) console.log(`${item.id}\t${item.exclusion ?? "run"}`); + for (const item of shard) { + console.log(`${item.id}\t${item.exclusion ?? (item.lifted ? "run (island lane)" : "run")}`); + } return; } + const liftedCases = shard.filter((item) => item.lifted).length; const cache = join(directory, "../../node_modules/.cache/scriptc-test262"); if (process.env.SCRIPTC_CACHE_DIR === undefined) { process.env.SCRIPTC_CACHE_DIR = join(cache, "cas"); mkdirSync(process.env.SCRIPTC_CACHE_DIR, { recursive: true, mode: 0o700 }); } process.env.SCRIPTC_TEST_STABLE_TOOLCHAIN ??= "1"; - const results: (Outcome & { id: string; path: string; variant: string; features: string[] })[] = []; + const results: (Outcome & { id: string; path: string; variant: string; features: string[]; lifted: boolean })[] = []; const journalPath = values.journal ? resolve(values.journal) : undefined; if (journalPath) { mkdirSync(dirname(journalPath), { recursive: true }); @@ -88,19 +111,26 @@ Other variants remain visible as exclusions.`); : await runSource(item.source, { backend, sanitize: process.env.SCRIPTC_SAN === "1", compileTimeoutMs, runtimeTimeoutMs, keep: values.keep, asyncTest: item.asyncTest, includes: item.includes, variant: item.variant === "sloppy" ? "sloppy" : "strict", + // A source-matched syntax negative stays the native compile + // refusal it has always been; it never becomes an executable. + islands: islands && item.negative === undefined, + liftedReason: item.liftedReason, }); const outcome = item.negative?.phase === "parse" && matchesParseNegative(raw, item.source, item.variant) ? { status: "pass", phase: "compile" } : raw; - const result = { id: item.id, path: item.path, variant: item.variant, features: item.features, ...outcome }; + const result = { id: item.id, path: item.path, variant: item.variant, features: item.features, lifted: item.lifted, ...outcome }; if (journalPath) appendFileSync(journalPath, `${JSON.stringify(result)}\n`); results.push(result); if (outcome.status !== "excluded") console.log(`${outcome.status}\t${item.id}`); } })); results.sort((a, b) => a.id.localeCompare(b.id)); - const unexpected = results.filter((r) => r.status !== "excluded" && - (values.root ? r.status !== "pass" : !matchesExpectation(r.id, r))); + // A lifted variant is newly measured, so its result is data rather than a + // native regression. Island passes satisfy the profile's pass expectation + // but stay a separate status in the counts (decision S4). + const unexpected = results.filter((r) => r.status !== "excluded" && !r.lifted && + (values.root ? r.status !== "pass" && r.status !== "island-pass" : !matchesExpectation(r.id, r))); const report = { schema: "scriptc.test262.v1", revision: pin.commit, snapshotSha256: pin.snapshotSha256, @@ -113,6 +143,7 @@ Other variants remain visible as exclusions.`); upstreamTestFiles: pin.testFiles, availableTestFiles: allPaths.length, selectedTestFiles: selected.length, selectedVariants: cases.length, shard: process.env.SCRIPTC_TEST_SHARD ?? null, shardVariants: shard.length, + islands, liftedVariants: liftedCases, unexpectedResults: unexpected.length, ...summarize(results), results, }; @@ -124,7 +155,8 @@ Other variants remain visible as exclusions.`); // Exclusions are visible but not failures. An all-excluded local selection // must not turn green; an empty distributed shard is legitimate. if (unexpected.length || - (!process.env.SCRIPTC_TEST_SHARD && !results.some((r) => r.status === "pass"))) process.exitCode = 1; + (!process.env.SCRIPTC_TEST_SHARD && + !results.some((r) => r.status === "pass" || r.status === "island-pass"))) process.exitCode = 1; } main().catch((error) => { diff --git a/tests/test262/support.mjs b/tests/test262/support.mjs index 46c601b7c..f4d8eb32c 100644 --- a/tests/test262/support.mjs +++ b/tests/test262/support.mjs @@ -121,10 +121,26 @@ function argumentsOwner(node) { return undefined; } +// The only exclusions the island lane lifts (decision S4): each of these +// names a construct the standalone strict adapter cannot express but Node's +// own engine can, and an island variant runs under Node. Everything else — +// the harness includes, the assertion surface, `$262` — stays excluded, +// because the adapter still has to run for an island too. +const LIFTED_IN_ISLAND = new Set([ + "host:eval", + "host:Function", + "host:arguments", + "host:script-environment", + "host:globalThis", +]); + // The first static profile adapts scripts to standalone strict modules. Be // conservative about observable global-script semantics and helper reflection. // Exclusions are runner limitations, never implementation support claims. -export function exclusion(source, meta, variant) { +// `islands` reports which variants the island lane could measure: it never +// widens the native profile, and it lifts the five host reasons on sloppy +// variants only. +export function exclusion(source, meta, variant, islands = false) { if (meta.negative) { if (meta.negative.phase !== "parse" || meta.negative.type !== "SyntaxError" || (variant !== "strict" && variant !== "sloppy")) { return `negative-phase:${meta.negative.phase}`; @@ -142,18 +158,20 @@ export function exclusion(source, meta, variant) { const forbidden = new Set(["$262", "$DONE", "$DONOTEVALUATE", "globalThis", "eval", "Function", "print", "process", "require"]); if (variant === "sloppy") for (const name of ["module", "exports", "__dirname", "__filename"]) forbidden.add(name); if (meta.flags.includes("async")) forbidden.delete("$DONE"); + const liftable = (item) => islands && variant === "sloppy" && LIFTED_IN_ISLAND.has(item); + const refuse = (item) => { if (reason === undefined && !liftable(item)) reason = item; }; const visit = (node) => { - if (reason) return; + if (reason !== undefined) return; if ((node.kind === ts.SyntaxKind.ThisKeyword && !hasOwnThisBinding(node)) || node.kind === ts.SyntaxKind.ImportKeyword || ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { - reason = "host:script-environment"; + refuse("host:script-environment"); } else if (ts.isIdentifier(node) && forbidden.has(node.text)) { - reason = `host:${node.text}`; + refuse(`host:${node.text}`); } else if (ts.isIdentifier(node) && node.text === "arguments" && (!argumentsOwner(node) || !(ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node && node.parent.name.text === "length") && !(ts.isElementAccessExpression(node.parent) && node.parent.expression === node))) { - reason = "host:arguments"; + refuse("host:arguments"); } else if (ts.isIdentifier(node) && node.text === "assert") { const parent = node.parent; if (ts.isCallExpression(parent) && parent.expression === node) { @@ -167,7 +185,7 @@ export function exclusion(source, meta, variant) { ["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"].includes(parent.parent.arguments[0].text))) ) { // Supported assertion calls; aliases, mutations, and reflection stay out. - } else reason = "harness:assert-surface"; + } else refuse("harness:assert-surface"); } else if (meta.includes.includes("compareArray.js") && ts.isIdentifier(node) && node.text === "compareArray" && !(ts.isCallExpression(node.parent) && node.parent.expression === node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === "assert")) { @@ -176,16 +194,16 @@ export function exclusion(source, meta, variant) { // function object and its format expando). reason = "harness:compareArray-surface"; } else if (ts.isIdentifier(node) && node.text === "Test262Error") { - if (!ts.isNewExpression(node.parent) || node.parent.expression !== node) reason = "harness:Test262Error-surface"; + if (!ts.isNewExpression(node.parent) || node.parent.expression !== node) refuse("harness:Test262Error-surface"); } else if (typedArrayHelper && ts.isIdentifier(node) && node.text === "TypedArray") { // The adapted testTypedArray.js leaves out %TypedArray%. - reason = "harness:TypedArray-intrinsic"; + refuse("harness:TypedArray-intrinsic"); } else if (propertyHelper && ts.isIdentifier(node)) { - if (unsupportedPropertyHelpers.has(node.text)) reason = "harness:propertyHelper-surface"; + if (unsupportedPropertyHelpers.has(node.text)) refuse("harness:propertyHelper-surface"); else if (node.text === "verifyProperty") { const call = node.parent; if (!ts.isCallExpression(call) || call.expression !== node || call.arguments.length < 3 || !inlinePropertyDescriptor(call.arguments[2])) { - reason = "harness:propertyHelper-surface"; + refuse("harness:propertyHelper-surface"); } } } @@ -207,6 +225,69 @@ export function prepare(source, asyncTest = false, marker = completion, variant return `"use strict";\nimport { ${names} } from "./harness.ts";\n${helpers}${source}\n${end}`; } +// The adapted test of the island lane (decision S4). It runs under Node, so +// `assert` is a JavaScript object built there: a native function crosses the +// bridge as a callable, but the namespace members attached to it do not (they +// are properties of the native function object). The harness exports each +// helper on its own for exactly this. +// +// The body normally runs as the island module itself. A lifted +// `host:script-environment` variant keeps its script shape instead: the +// module carries the body between these markers only as a dead copy the +// pre-scan can island, and replays it as script code inside the island. +const scriptBegin = "//@@SCRIPTC_TEST262_SCRIPT_BEGIN@@"; +const scriptEnd = "//@@SCRIPTC_TEST262_SCRIPT_END@@"; + +export function prepareIsland(source, asyncTest = false, marker = completion, includes = [], script = false) { + const helpers = + (includes.includes("propertyHelper.js") ? `${propertyHelperSource}\n` : "") + + (includes.includes("testTypedArray.js") ? `${typedArrayHelperSource}\n` : ""); + const end = asyncTest ? "" : `;console.log(${JSON.stringify(marker)});\n`; + const lines = [ + 'const harness = require("./harness.ts");', + "const assert = (value, message) => harness.assert(value, message);", + "assert.sameValue = harness.sameValue;", + "assert.notSameValue = harness.notSameValue;", + "assert.throws = harness.assertThrows;", + "assert.compareArray = harness.compareArray;", + "const Test262Error = harness.Test262Error;", + ...(asyncTest ? ["const $DONE = harness.$DONE;"] : []), + ]; + if (!script) return `${lines.join("\n")}\n${helpers}${source}\n${end}`; + // A lifted `host:script-environment` variant is a script, not a module: its + // top-level `this` is the global object and its top-level `var` bindings are + // global properties. The island therefore replays the body as script code, + // and the module carries it only as a dead copy the pre-scan can island. + const globals = ["globalThis.assert = assert;", "globalThis.Test262Error = Test262Error;"]; + return [ + 'const { readFileSync } = require("node:fs");', + 'const vm = require("node:vm");', + ...lines, + ...globals, + `const begin = ${JSON.stringify(scriptBegin)};`, + `const stop = ${JSON.stringify(scriptEnd)};`, + 'const text = readFileSync(__filename, "utf8");', + // The markers also appear in the `begin`/`stop` definitions, so the body + // is the last block that starts with the begin marker. + "const from = text.lastIndexOf(begin);", + "const to = text.indexOf(stop, from + begin.length);", + 'if (from < 0 || to < 0) throw new Error("the adapted script markers are missing");', + `vm.runInThisContext(text.slice(from + begin.length, to) + ${JSON.stringify(end)}, { filename: __filename });`, + "if (false) { (function () {", + scriptBegin, + `${helpers}${source}`, + scriptEnd, + "}); }", + ].join("\n"); +} + +/** `host:script-environment` also covers ESM syntax; only a CommonJS-shaped + * test can be replayed as a script inside its island. */ +export function scriptEnvironment(source) { + const sf = ts.createSourceFile("test.js", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + return !ts.isExternalModule(sf); +} + function parseDiagnostics(source, variant) { if (variant !== "strict" && variant !== "sloppy") return []; const prepared = prepare(source, false, completion, variant); @@ -310,8 +391,13 @@ export function compileFailureStatus(diagnostics) { } export function matchesExpectation(id, outcome) { + // Decision S4: a variant whose entry module ran in a Node island passes + // for the profile — it proved Node's semantics, which is what the + // expectation asserts — but it stays a separate status, so the counts + // never report it as a native pass. + const status = outcome.status === "island-pass" ? "pass" : outcome.status; const expected = expectations[id]; - if (!expected) return outcome.status === "pass"; - return outcome.status === expected.status && outcome.reason === expected.reason && + if (!expected) return status === "pass"; + return status === expected.status && outcome.reason === expected.reason && outcome.stderr?.includes(expected.detail) === true; } From 28de3ca7c6356e087e495011a3021d5b8a700bc6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:49:42 -0700 Subject: [PATCH 12/44] Island modules that patch a builtin module's CommonJS exports Detect writes through a builtin module's CommonJS object (direct, aliased, namespace, defineProperty, Object.assign, delete) as the new island reason `builtin-patch` (R2.5), in Node islands they are collected alongside npm and user modules, and the SC8001 note names the patched member. A patching entry module is refused with SC8006 ("move the patch into a module of its own"), as is a patcher whose TypeScript needs more than Node's type stripping. A native module that reads a patched member through the CommonJS object gets SC8020, and npm packages that read one join the island set. Program modules also ship data files they name beside themselves, since an island loads from the extracted tree. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/diagnostics/diagnostic.ts | 49 ++++ packages/compiler/src/executable/prepare.ts | 158 +++++++++++- .../compiler/src/node-islands/detect.test.ts | 84 ++++++- packages/compiler/src/node-islands/detect.ts | 236 +++++++++++++++--- .../compiler/src/node-islands/modules.test.ts | 20 ++ packages/compiler/src/node-islands/modules.ts | 28 +++ .../compiler/src/node-islands/report.test.ts | 2 + packages/compiler/src/node-islands/rules.ts | 1 + packages/compiler/src/node-islands/scan.ts | 22 +- packages/compiler/src/node-islands/summary.ts | 9 + .../compiler/src/node-islands/type-strip.ts | 181 ++++++++++++++ .../main.ts.json | 17 ++ .../fallback/payload.txt | 1 + .../6897-node-island-builtin-patch/loader.ts | 39 +++ .../6897-node-island-builtin-patch/main.ts | 19 ++ .../node_modules/payload-reader/index.js | 5 + .../node_modules/payload-reader/package.json | 1 + .../package.json | 1 + .../main.ts.json | 1 + tests/harness/node-island.test.ts | 166 ++++++++++++ 20 files changed, 995 insertions(+), 45 deletions(-) create mode 100644 packages/compiler/src/node-islands/type-strip.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-builtin-patch/main.ts.json create mode 100644 tests/corpus/6897-node-island-builtin-patch/fallback/payload.txt create mode 100644 tests/corpus/6897-node-island-builtin-patch/loader.ts create mode 100644 tests/corpus/6897-node-island-builtin-patch/main.ts create mode 100644 tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/index.js create mode 100644 tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/package.json create mode 100644 tests/corpus/6897-node-island-builtin-patch/package.json create mode 100644 tests/harness/corpus-costs/6897-node-island-builtin-patch/main.ts.json diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index a1b2f071f..f03a30874 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -354,6 +354,55 @@ export function nativeSubclassOfIslandDiag(loc: SrcLoc, detail?: string): ScrDia }; } +/** SC8006 — the entry module patches a builtin module's CommonJS object. + * The entry always stays native (a whole-file island is a different + * feature), so the patch cannot run: it belongs in a module of its own, + * which then runs in a Node island. */ +export function nodeIslandEntryPatchDiag( + file: string, + member: string, + module: string, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8006", + message: `the entry module patches '${member}' on the CommonJS ${module} object; move the patch into a module of its own, which then runs in a Node island`, + loc, + hint: `the entry module ${file} stays native; only a separate module runs in Node`, + }; +} + +/** SC8006 — a user TypeScript module patches a builtin module's CommonJS + * object but uses syntax Node cannot run with type stripping only. */ +export function nodeIslandStripDiag(file: string, construct: string, loc: SrcLoc): ScrDiagnostic { + return { + code: "SC8006", + message: `'${file}' patches a builtin module's CommonJS object, but Node cannot strip its types: ${construct}`, + loc, + hint: "Node runs TypeScript by erasing types: avoid enums, namespaces, constructor parameter properties, decorators, JSX, import =/export =, extensionless relative imports and value imports of types; a separate .js module can hold this patch", + }; +} + +/** SC8020 — native code reads a builtin module member that an island + * module patches: Node would show the read the patch, the native build + * cannot. ES module bindings of builtins never see the patch (in Node + * either), so the named import is the exact spelling. */ +export function nodeIslandPatchedReadDiag( + member: string, + patchFile: string, + loc: SrcLoc, +): ScrDiagnostic { + const dot = member.indexOf("."); + const module = dot < 0 ? member : member.slice(0, dot); + const property = dot < 0 ? member : member.slice(dot + 1); + return { + code: "SC8020", + message: `'${member}' is patched by ${patchFile}, which runs in a Node island; Node would show this read through the CommonJS ${module} object the patch, the native build cannot. Use \`import { ${property} } from "node:${module}"\` (ES module bindings never see the patch, in Node either), or move this code into ${patchFile}.`, + loc, + hint: `node:${module} ES module bindings keep the original function, in Node too`, + }; +} + /** SC8020 — a stateful module the program compiles is duplicated across * the Node island boundary (an explicit "duplicate", or a form NI-6 cannot * share yet): each side has its own state. */ diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 2d1e30fbc..fd71975c1 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -4,8 +4,11 @@ import type { FfiProfile } from "../ffi/ffi-manifest.js"; import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, + nodeIslandEntryPatchDiag, nodeIslandModuleNoteDiag, nodeIslandNoteDiag, + nodeIslandPatchedReadDiag, + nodeIslandStripDiag, nodeIslandRetryFailedDiag, npmStaticRefusalDiag, iceDiag, @@ -43,6 +46,7 @@ import { describe } from "../node-islands/summary.js"; import { resolveBareModule } from "../frontend/resolve.js"; import { isEsmFile } from "../frontend/lowering/lower-node-island.js"; import type { NodeIslandScan, PackageEdge } from "../node-islands/scan.js"; +import type { NodeIslandMarker } from "../node-islands/detect.js"; import { isJsSourceFileName } from "../frontend/tsc-codes.js"; export interface PreparedExecutableModule { @@ -83,6 +87,14 @@ export function prepareExecutableModule( // unless the policy says otherwise. The frontend reloads without them, // so their declarations type the program. const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands, lowerModules); + // A patched builtin the native build cannot represent (an entry module + // patch, or a native read of a patched member) fails here. + if (islandDecision !== null && islandDecision.errors.length > 0) { + const diagnostics = islandDecision.errors; + const sourceTexts = fe.sourceTexts(); + fe.dispose(); + return { ok: false, diagnostics, sourceTexts }; + } if (islandDecision !== null) { fe.dispose(); fe = createFrontend(entryPath, opts.npmStatic, undefined, undefined, { @@ -362,25 +374,57 @@ const SLOPPY_REASONS: ReadonlySet = new Set([ "sloppy-this", ]); +/** A module that patches a builtin module's CommonJS object (the user's + * decision, 2026-10-29): its patch is meant for code that shares Node's + * builtin, so it runs in Node, where an island consumer sees it. */ +const PATCH_REASONS: ReadonlySet = new Set(["builtin-patch"]); + /** The program modules that run in Node islands, by file: modules marked * with `--node-island=` or `scriptc.nodeIslands.modules`, and (under * `--node-islands=auto`) user JavaScript the pre-scan found a sloppy-only * construct in (decision S3). The entry stays native: a program whose entry * is an island runs the whole file in Node, which is a different feature. */ +interface ModuleIslandDecision { + modules: Map; + /** The entry module's own `builtin-patch` marker: the entry stays native + * (a whole-file island is a different feature), so the build refuses it + * with SC8006. */ + entryPatch: { file: string; marker: NodeIslandMarker } | null; + /** User TypeScript modules with a `builtin-patch` marker that Node + * cannot run with type stripping only (H3.5): SC8006, kept native. */ + stripProblems: { file: string; construct: string; loc: SrcLoc }[]; +} + function moduleIslands( entryPath: string, policy: NodeIslandPolicy, scan: NodeIslandScan, -): Map { +): ModuleIslandDecision { const modules = new Map(); + let entryPatch: ModuleIslandDecision["entryPatch"] = null; + const stripProblems: ModuleIslandDecision["stripProblems"] = []; for (const m of scan.modules) { if (m.origin !== "user") continue; - if (!isJsSourceFileName(m.file)) continue; - if (sameFile(m.file, entryPath)) continue; + const patches = m.markers.filter((marker) => PATCH_REASONS.has(marker.reason)); + // A TypeScript module islands only for a builtin patch (H3.2). + if (!isJsSourceFileName(m.file) && patches.length === 0) continue; + if (sameFile(m.file, entryPath)) { + if (patches.length > 0 && entryPatch === null) + entryPatch = { file: m.file, marker: patches[0]! }; + continue; + } + if (m.stripProblem !== null) { + stripProblems.push({ file: m.file, ...m.stripProblem }); + continue; + } const marked = isMarkedUserModule(policy, m.file); const sloppy = m.markers.filter((marker) => SLOPPY_REASONS.has(marker.reason)); - if (marked === null && (policy.mode !== "auto" || sloppy.length === 0)) continue; - const first = sloppy[0]; + if ( + marked === null && + (policy.mode !== "auto" || (sloppy.length === 0 && patches.length === 0)) + ) + continue; + const first = sloppy[0] ?? patches[0]; const reason = first === undefined ? null : classifyMarker(first); modules.set(m.file, { file: m.file, @@ -394,7 +438,7 @@ function moduleIslands( decision: marked === null ? "auto" : "marked", }); } - return modules; + return { modules, entryPatch, stripProblems }; } /** The packages and modules a build runs in Node islands, or null for none: @@ -409,7 +453,7 @@ function decideNodeIslands( fe: ReturnType, lowerIslands: ReadonlyMap, lowerModules: ReadonlyMap, -): (NodeIslandSet & { notes: ScrDiagnostic[] }) | null { +): (NodeIslandSet & { notes: ScrDiagnostic[]; errors: ScrDiagnostic[] }) | null { const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); if (policy.mode === "off" || fe.nodeIslandScan === undefined) return null; // A package marked native stays refused: the build reports it rather @@ -425,11 +469,68 @@ function decideNodeIslands( // The pre-scan's user-JavaScript markers exist only where a build may act // on them. const scan = fe.nodeIslandScan({ userJs: policy.mode === "auto" }); - const modules = moduleIslands(entryPath, policy, scan); + const moduleDecision = moduleIslands(entryPath, policy, scan); + const modules = moduleDecision.modules; // Modules the lowering refused keep their first reason (the reload no // longer compiles them). for (const [file, island] of lowerModules) if (!modules.has(file)) modules.set(file, island); - if (refused.size === 0 && policy.forcedNode.size === 0 && modules.size === 0) return null; + const errors: ScrDiagnostic[] = []; + if (moduleDecision.entryPatch !== null) { + const { file, marker } = moduleDecision.entryPatch; + errors.push( + nodeIslandEntryPatchDiag( + file, + marker.member ?? marker.construct, + builtinModuleOf(marker.member), + marker.loc, + ), + ); + } + for (const problem of moduleDecision.stripProblems) + errors.push(nodeIslandStripDiag(problem.file, problem.construct, problem.loc)); + // Who else must see the patch (H3.4): every non-islanded module that + // reads a patched member through the builtin's CommonJS object. An npm + // package joins the island set (a patch meant for it must reach it); a + // user module is SC8020, because native code cannot see the patch. + const patchedMembers = new Map(); + for (const m of scan.modules) { + if (!modules.has(m.file)) continue; + for (const marker of m.markers) { + if (!PATCH_REASONS.has(marker.reason) || marker.member === undefined) continue; + if (!patchedMembers.has(marker.member)) patchedMembers.set(marker.member, m.file); + } + } + const readerPackages = new Map< + string, + { packageDir: string; member: string; patchFile: string } + >(); + if (policy.mode === "auto" && patchedMembers.size > 0) { + for (const m of scan.modules) { + if (modules.has(m.file)) continue; + for (const read of m.cjsReads) { + const patchFile = patchFileOf(patchedMembers, read.member); + if (patchFile === undefined) continue; + if (m.origin === "npm" && m.packageName !== null && m.packageDir !== null) { + if (!readerPackages.has(m.packageName) && !refused.has(m.packageName)) + readerPackages.set(m.packageName, { + packageDir: m.packageDir, + member: read.member, + patchFile, + }); + } else if (m.origin === "user") { + errors.push(nodeIslandPatchedReadDiag(read.member, patchFile, read.loc)); + } + } + } + } + if (errors.length > 0) return { packages: new Map(), modules: new Map(), notes: [], errors }; + if ( + refused.size === 0 && + policy.forcedNode.size === 0 && + modules.size === 0 && + readerPackages.size === 0 + ) + return null; const report = buildNodeIslandReport({ entry: entryPath, scan, @@ -505,6 +606,29 @@ function decideNodeIslands( ), ); } + // npm packages that read a patched member join the island set: their + // patch lives in the island's Node, and only a shared builtin shows it. + for (const [name, reader] of readerPackages) { + if (packages.has(name)) continue; + const version = packageVersionOf(reader.packageDir); + const reason = `it reads '${reader.member}' through the CommonJS ${builtinModuleOf(reader.member)} object, which island module ${reader.patchFile} patches`; + packages.set(name, { + name, + version, + dir: reader.packageDir, + class: "object-model", + reason, + }); + notes.push( + nodeIslandNoteDiag( + name, + version, + "object-model", + reason, + fe.npmImportSites.get(name) ?? { file: entryPath, start: 0, end: 0 }, + ), + ); + } for (const island of [...modules.values()].sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0, )) @@ -515,7 +639,21 @@ function decideNodeIslands( end: 0, }), ); - return packages.size === 0 && modules.size === 0 ? null : { packages, modules, notes }; + return packages.size === 0 && modules.size === 0 ? null : { packages, modules, notes, errors }; +} + +/** The builtin module a patched member belongs to ("fs" of + * "fs.readFileSync"). */ +function builtinModuleOf(member: string | undefined): string { + if (member === undefined) return ""; + const dot = member.indexOf("."); + return dot < 0 ? member : member.slice(0, dot); +} + +/** The island module that patches `member`, matching an exact member or a + * whole-module patch ("fs.*"). */ +function patchFileOf(patched: ReadonlyMap, member: string): string | undefined { + return patched.get(member) ?? patched.get(`${builtinModuleOf(member)}.*`); } /** The island set grown by the npm packages whose own modules refused to diff --git a/packages/compiler/src/node-islands/detect.test.ts b/packages/compiler/src/node-islands/detect.test.ts index 72cbae9c7..52b577da8 100644 --- a/packages/compiler/src/node-islands/detect.test.ts +++ b/packages/compiler/src/node-islands/detect.test.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, test } from "vitest"; import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js"; -import { scanModule } from "./detect.js"; +import { scanModule, scanModuleFacts } from "./detect.js"; afterAll(() => closeSourceParser()); @@ -213,7 +213,7 @@ describe("R2 object-model markers", () => { expect(markers("Promise = Bluebird;")).toEqual(["R2.5 builtin-write"]); expect(markers("globalThis.fetch = f;")).toEqual(["R2.5 builtin-write"]); expect(markers("const fs = require('fs'); fs.close = patched;")).toEqual([ - "R2.5 builtin-write", + "R2.5 builtin-patch", ]); expect(markers("const p = new Proxy(t, h);")).toEqual(["R2.5 proxy"]); // Modeled process state and tuning knobs stay; so do redeclared names. @@ -224,6 +224,86 @@ describe("R2 object-model markers", () => { }); }); +describe("R2.5 builtin patches (H3.1)", () => { + /** The markers and the member each builtin patch names. */ + function patches(source: string, ext = "cjs"): string[] { + const sf = parseSourceFile(`/virtual/p${serial++}.${ext}`, source, "js"); + return scanModuleFacts(sf).markers.map( + (m) => `${m.reason} ${m.member === undefined ? "-" : m.member}`, + ); + } + + test("a write through the CommonJS object is a builtin patch", () => { + expect(patches("const fs = require('fs'); fs.readFileSync = f;")).toEqual([ + "builtin-patch fs.readFileSync", + ]); + }); + + test("createRequire(), casts and aliases resolve to the same object", () => { + expect( + patches( + "const r = createRequire(import.meta.url); const fs = r('node:fs') as typeof import('fs'); (fs as { readFileSync: unknown }).readFileSync = f;", + "mjs", + ), + ).toEqual(["builtin-patch fs.readFileSync"]); + expect( + patches( + "const r = createRequire(import.meta.url); const fs = r('node:fs') as typeof import('fs'); const m = fs as { readFileSync: unknown }; m.readFileSync = f;", + "mjs", + ), + ).toEqual(["builtin-patch fs.readFileSync"]); + }); + + test("a default import is the CommonJS object", () => { + expect(patches("import fs from 'node:fs'; fs.writeFileSync = f;", "mjs")).toEqual([ + "builtin-patch fs.writeFileSync", + ]); + }); + + test("defineProperty, Object.assign, delete and bind are patches too", () => { + expect( + patches("const fs = require('fs'); Object.defineProperty(fs, 'readFileSync', { value: f });"), + ).toEqual(["builtin-patch fs.readFileSync"]); + expect(patches("const fs = require('fs'); Object.assign(fs, { readFileSync: f });")).toEqual([ + "builtin-patch fs.readFileSync", + ]); + expect(patches("const fs = require('fs'); Object.assign(fs, source);")).toEqual([ + "builtin-patch fs.*", + ]); + expect(patches("const fs = require('fs'); delete fs.readFileSync;")).toEqual([ + "builtin-patch fs.readFileSync", + ]); + expect( + patches("const fs = require('fs'); fs.readFileSync = fs.readFileSync.bind(fs);"), + ).toEqual(["builtin-patch fs.readFileSync"]); + }); + + test("writes Node itself refuses are not patches", () => { + // A namespace import is not the CommonJS object: Node throws on the + // write, and the module stays as it is today. + expect(patches("import * as fs from 'node:fs'; fs.x = 1;", "mjs")).toEqual(["builtin-write -"]); + expect(patches("import { readFileSync } from 'node:fs'; readFileSync(p);", "mjs")).toEqual([]); + expect(patches("const fs = { readFileSync: 1 }; fs.readFileSync = 2;")).toEqual([]); + expect(patches("process.exitCode = 1;")).toEqual([]); + }); +}); + +test("CommonJS object reads are recorded for the patch check (H3.4)", () => { + const reads = (source: string, ext = "cjs"): string[] => { + const sf = parseSourceFile(`/virtual/r${serial++}.${ext}`, source, "js"); + return scanModuleFacts(sf).cjsReads.map((read) => read.member); + }; + expect(reads("const fs = require('fs'); fs.readFileSync(p);")).toEqual(["fs.readFileSync"]); + expect(reads("import { readFileSync } from 'node:fs'; readFileSync(p);", "mjs")).toEqual([]); + // A write is not a read; an ES module read through the namespace import + // is not the CommonJS object either. + expect(reads("const fs = require('fs'); fs.readFileSync = f;")).toEqual([]); + expect(reads("import fs from 'node:fs'; fs.readFileSync(p);", "mjs")).toEqual([ + "fs.readFileSync", + ]); + expect(reads("import * as fs from 'node:fs'; fs.readFileSync(p);", "mjs")).toEqual([]); +}); + test("markers come back in source order with their locations", () => { const source = "Object.setPrototypeOf(a, b);\neval(x);\n"; const sf = parseSourceFile(`/virtual/order${serial++}.cjs`, source, "js"); diff --git a/packages/compiler/src/node-islands/detect.ts b/packages/compiler/src/node-islands/detect.ts index e0c341bd5..926e1d6b8 100644 --- a/packages/compiler/src/node-islands/detect.ts +++ b/packages/compiler/src/node-islands/detect.ts @@ -25,6 +25,18 @@ export interface NodeIslandMarker { /** The construct, as a short phrase for messages. */ construct: string; loc: SrcLoc; + /** For `builtin-patch`: the builtin module member the module patches + * ("fs.readFileSync"; "fs.*" when the source is not an object literal). */ + member?: string; +} + +/** A member read through a builtin module's CommonJS object (H3.4): the + * patch a Node island module installs is visible to such a read, which a + * native build cannot reproduce. */ +export interface CjsRead { + /** The member read ("fs.readFileSync"). */ + member: string; + loc: SrcLoc; } /** Markers past this count in one module add nothing to a report. */ @@ -127,6 +139,23 @@ function builtinModuleName(spec: string): string | null { return null; } +/** Parentheses and the TypeScript wrappers Node erases (`as`, + * `satisfies`, `!` and the angle-bracket assertion). */ +function stripWrappers(node: ts.Node): ts.Node { + let value = node; + for (;;) { + if ( + ts.isParenthesizedExpression(value) || + ts.isAsExpression(value) || + ts.isTypeAssertion(value) || + ts.isNonNullExpression(value) || + ts.isSatisfiesExpression(value) + ) + value = value.expression; + else return value; + } +} + function literalText(node: ts.Node | undefined): string | null { if (node === undefined) return null; if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; @@ -147,6 +176,15 @@ function isAssignmentOperator(kind: ts.SyntaxKind): boolean { return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; } +/** The key of an object-literal property (`name`, `"name"` or a + * template without substitutions). */ +function literalKeyName(name: ts.Node | undefined): string | null { + if (name === undefined) return null; + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) + return name.text; + return null; +} + function propertyName(node: ts.Node): string | null { if (ts.isPropertyAccessExpression(node)) { const name = node.name; @@ -159,12 +197,12 @@ function propertyName(node: ts.Node): string | null { /** `a.b.c` as "a.b.c" (element accesses with literal keys included), or * null when the chain has a computed key or does not start at a name. */ function chainText(node: ts.Node): string | null { - if (ts.isIdentifier(node)) return node.text; - if (node.kind === ts.SyntaxKind.ThisKeyword) return "this"; - if (ts.isParenthesizedExpression(node)) return chainText(node.expression); - if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { - const base = chainText(node.expression); - const name = propertyName(node); + const value = stripWrappers(node); + if (ts.isIdentifier(value)) return value.text; + if (value.kind === ts.SyntaxKind.ThisKeyword) return "this"; + if (ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value)) { + const base = chainText(value.expression); + const name = propertyName(value); if (base === null || name === null) return null; return `${base}.${name}`; } @@ -173,15 +211,16 @@ function chainText(node: ts.Node): string | null { /** Like chainText, with computed keys spelled `[…]`: `process.env[…]`. */ function memberText(node: ts.Node): string | null { - const text = chainText(node); + const value = stripWrappers(node); + const text = chainText(value); if (text !== null) return text; - if (ts.isElementAccessExpression(node)) { - const base = memberText(node.expression); + if (ts.isElementAccessExpression(value)) { + const base = memberText(value.expression); return base === null ? null : `${base}[…]`; } - if (ts.isPropertyAccessExpression(node)) { - const base = memberText(node.expression); - const name = propertyName(node); + if (ts.isPropertyAccessExpression(value)) { + const base = memberText(value.expression); + const name = propertyName(value); return base === null || name === null ? null : `${base}.${name}`; } return null; @@ -189,15 +228,11 @@ function memberText(node: ts.Node): string | null { /** The identifier a member chain starts at (`a` of `a.b[c].d`). */ function chainRoot(node: ts.Node): ts.Identifier | null { - let cur = node; + let cur = stripWrappers(node); for (;;) { if (ts.isIdentifier(cur)) return cur; if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur)) { - cur = cur.expression; - continue; - } - if (ts.isParenthesizedExpression(cur)) { - cur = cur.expression; + cur = stripWrappers(cur.expression); continue; } return null; @@ -275,6 +310,12 @@ class ModuleScanner { private readonly builtinBindings = new Map(); /** Local names bound to a createRequire(...) result. */ private readonly createdRequires = new Set(); + /** Local names bound to a builtin module's CommonJS object: require(), + * a createRequire'd require's result, a default import, or an alias of + * one. Writes through these names are patches of the module object. */ + private readonly cjsBuiltinObjects = new Set(); + /** Members read through a CommonJS builtin object (H3.4). */ + readonly cjsReads: CjsRead[] = []; /** Names the file constructs with `new`: a constructor's `this` is never * the global object, so its `this` uses need no island (decision S5). */ private readonly constructed = new Set(); @@ -306,13 +347,20 @@ class ModuleScanner { return this.markers; } - private add(rule: NodeIslandRule, reason: string, construct: string, node: ts.Node): void { + private add( + rule: NodeIslandRule, + reason: string, + construct: string, + node: ts.Node, + member?: string, + ): void { if (this.markers.length >= MARKER_CAP) return; this.markers.push({ rule, reason, construct, loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end }, + ...(member === undefined ? {} : { member }), }); } @@ -321,13 +369,16 @@ class ModuleScanner { if (ts.isIdentifier(name)) { this.declared.add(name.text); if (init === undefined) return; - let value = init; - while (ts.isParenthesizedExpression(value)) value = value.expression; + const value = stripWrappers(init); if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) this.functions.add(name.text); else if (ts.isClassExpression(value)) this.classes.add(name.text); const bound = this.builtinOf(value); - if (bound !== null) this.builtinBindings.set(name.text, bound); + if (bound !== null) { + this.builtinBindings.set(name.text, bound); + if (!bound.includes(".") && this.isCjsObjectValue(value)) + this.cjsBuiltinObjects.add(name.text); + } if (ts.isCallExpression(value) && this.isCreateRequireCallee(value.expression)) this.createdRequires.add(name.text); return; @@ -353,11 +404,25 @@ class ModuleScanner { } } + /** A value that is a builtin module's CommonJS object: a require() or a + * createRequire'd require's result of a builtin, or an alias of one. */ + private isCjsObjectValue(value: ts.Node): boolean { + if (ts.isIdentifier(value)) return this.cjsBuiltinObjects.has(value.text); + if (!ts.isCallExpression(value)) return false; + return ( + this.isRequireCallee(value.expression) || + (ts.isIdentifier(value.expression) && this.createdRequires.has(value.expression.text)) + ); + } + /** `require("fs")`, `require("node:util").inherits` → "fs", "util.inherits". */ private builtinOf(node: ts.Node): string | null { - let value = node; - while (ts.isParenthesizedExpression(value)) value = value.expression; - if (ts.isCallExpression(value) && this.isRequireCallee(value.expression)) { + const value = stripWrappers(node); + if ( + ts.isCallExpression(value) && + (this.isRequireCallee(value.expression) || + (ts.isIdentifier(value.expression) && this.createdRequires.has(value.expression.text))) + ) { const spec = literalText(value.arguments.nodes[0]); return spec === null ? null : builtinModuleName(spec); } @@ -414,7 +479,12 @@ class ModuleScanner { if (clause !== undefined) { if (clause.name !== undefined) { this.declared.add(clause.name.text); - if (module !== null) this.builtinBindings.set(clause.name.text, module); + if (module !== null) { + this.builtinBindings.set(clause.name.text, module); + // A default import is the CommonJS object (unlike a namespace + // or named import, through which Node refuses writes). + this.cjsBuiltinObjects.add(clause.name.text); + } } const bindings = clause.namedBindings; if (bindings !== undefined && ts.isNamespaceImport(bindings)) { @@ -562,6 +632,7 @@ class ModuleScanner { const text = chainText(node); if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node)) this.add("R2.4", "sloppy-arguments", `'${text}'`, node); + this.recordCjsRead(node); } else if (node.kind === ts.SyntaxKind.ThisKeyword) { this.visitThis(node); } else if (ts.isObjectLiteralExpression(node)) { @@ -577,6 +648,37 @@ class ModuleScanner { } } + /** Records a read of a member of a builtin module's CommonJS object, so + * a patch installed by an island module can be checked against it + * (H3.4). The left of an assignment (and a delete or ++/-- target) is a + * write, not a read. */ + private recordCjsRead(node: ts.PropertyAccessExpression): void { + const root = chainRoot(node); + if (root === null || !this.cjsBuiltinObjects.has(root.text)) return; + const module = this.builtinBindings.get(root.text); + const text = chainText(node); + if (module === undefined || text === null) return; + const parent = node.parent; + if (parent !== undefined) { + if ( + ts.isBinaryExpression(parent) && + isAssignmentOperator(parent.operatorToken.kind) && + parent.left === node + ) + return; + if ( + ts.isDeleteExpression(parent) || + ts.isPrefixUnaryExpression(parent) || + ts.isPostfixUnaryExpression(parent) + ) + return; + } + this.cjsReads.push({ + member: module + text.slice(root.text.length), + loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end }, + }); + } + private visitSpecifier(spec: string, node: ts.Node): void { const builtin = builtinModuleName(spec); if (builtin === "vm") this.add("R1.1", "vm", "the 'vm' module", node); @@ -691,10 +793,67 @@ class ModuleScanner { this.isGlobal("Object")) || (text === "Reflect.defineProperty" && this.isGlobal("Reflect")) ) { + // A definition on a builtin module's CommonJS object is a patch the + // code loading that object must see (R2.5, H3.1.4). + const target = first === undefined ? undefined : stripWrappers(first); + const builtin = + target !== undefined && ts.isIdentifier(target) && this.cjsBuiltinObjects.has(target.text) + ? (this.builtinBindings.get(target.text) ?? null) + : null; + if (builtin !== null) { + const key = literalText(args[1] === undefined ? undefined : stripWrappers(args[1])); + this.add( + "R2.5", + "builtin-patch", + `'${text}()' on the builtin module '${builtin}'`, + call, + key === null ? `${builtin}.*` : `${builtin}.${key}`, + ); + return; + } if (first !== undefined && !this.isModuleObject(first) && !this.inInteropHelper(call)) this.add("R2.3", "define-property", `'${text}()'`, call); return; } + // Object.assign(fs, …) on a builtin module's CommonJS object: every + // literal key is a patch (R2.5, H3.1.4). + if (text === "Object.assign" && this.isGlobal("Object") && first !== undefined) { + const target = stripWrappers(first); + if (ts.isIdentifier(target) && this.cjsBuiltinObjects.has(target.text)) { + const builtin = this.builtinBindings.get(target.text) ?? null; + if (builtin !== null) { + const source = args.length === 2 ? stripWrappers(args[1]!) : undefined; + const keys = + source !== undefined && ts.isObjectLiteralExpression(source) + ? source.properties.nodes + .map((property) => + ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property) + ? literalKeyName(property.name) + : null, + ) + .filter((key): key is string => key !== null) + : []; + if (keys.length === 0) + this.add( + "R2.5", + "builtin-patch", + `'Object.assign()' on the builtin module '${builtin}'`, + call, + `${builtin}.*`, + ); + else + for (const key of keys) + this.add( + "R2.5", + "builtin-patch", + `'Object.assign()' on the builtin module '${builtin}'`, + call, + `${builtin}.${key}`, + ); + return; + } + } + } if (text.endsWith(".__defineGetter__") || text.endsWith(".__defineSetter__")) { this.add("R2.3", "define-property", `'${text.slice(text.lastIndexOf(".") + 1)}()'`, call); return; @@ -850,11 +1009,15 @@ class ModuleScanner { if (binding !== undefined) { const member = binding + spelled.slice(root.text.length); if (!this.allowedWrite(member)) { + // A write through a builtin module's CommonJS object patches that + // object; only code sharing Node's builtin can see it (H3.1). + const patch = this.cjsBuiltinObjects.has(root.text); this.add( "R2.5", - "builtin-write", + patch ? "builtin-patch" : "builtin-write", `a ${what} of the builtin module member '${member}'`, node, + patch ? member : undefined, ); } return; @@ -966,8 +1129,21 @@ interface ArgumentsUse { paramWrite: ts.Node | null; } -/** The R1/R2 markers of one JavaScript module, in source order. */ +/** The R1/R2 markers and CommonJS-object reads of one module, in source + * order. */ +export function scanModuleFacts(sf: ts.SourceFile): { + markers: NodeIslandMarker[]; + cjsReads: CjsRead[]; +} { + const scanner = new ModuleScanner(sf); + const markers = scanner.scan(); + return { + markers: markers.sort((a, b) => a.loc.start - b.loc.start), + cjsReads: scanner.cjsReads.sort((a, b) => a.loc.start - b.loc.start), + }; +} + +/** The R1/R2 markers of one module, in source order. */ export function scanModule(sf: ts.SourceFile): NodeIslandMarker[] { - const markers = new ModuleScanner(sf).scan(); - return markers.sort((a, b) => a.loc.start - b.loc.start); + return scanModuleFacts(sf).markers; } diff --git a/packages/compiler/src/node-islands/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index 2c0d8ec73..8f443a5b5 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -249,6 +249,26 @@ describe("the collected module tree", () => { expect(NodeIslandModuleCollector.callerPath(null, join(root, "main.ts"))).toBe("main.ts"); }); + test("a data file the module names beside itself ships with it", () => { + const root = project({ + "src/main.ts": "", + "src/loader.ts": [ + 'import { join } from "node:path";', + 'const here = "/x";', + 'const fallback = join(here, "fallback", "payload.txt");', + ].join("\n"), + "src/fallback/payload.txt": "from the fallback path\n", + "src/unrelated.txt": "not named by the module\n", + "src/helper.ts": "export const h = 1;", + }); + const collector = new NodeIslandModuleCollector(); + expect(collector.add(join(root, "src/main.ts"), "./loader.ts")).toBeNull(); + const paths = collector.tree()!.files.map((file) => file.path); + expect(paths).toContain("fallback/payload.txt"); + expect(paths).not.toContain("unrelated.txt"); + expect(paths).not.toContain("helper.ts"); + }); + test("the manifest names relative paths only, and the data is checked against it", () => { const root = project({ "main.ts": "", "m.cjs": "exports.f = () => 1;" }); const collector = new NodeIslandModuleCollector(); diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index 7fc5c46d7..ead084088 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -324,6 +324,7 @@ export class NodeIslandModuleCollector { if (!TRACED.test(file)) return null; const text = trackedReadFile(file); if (text === null) return `cannot read ${file}`; + this.addNamedData(file, text); const scan = scanModuleSpecifiers(text); if (scan.computed.length > 0) { const first = scan.computed[0]!; @@ -337,6 +338,33 @@ export class NodeIslandModuleCollector { return null; } + /** Data files beside a program module that runs in an island: the module + * resolves them from its own directory (photon redirects a package's + * read to the wasm next to the patching module), and the island runs from + * the shipped tree, so a file the module names by a string literal ships + * with it. Files the source does not name stay out, and module sources + * ship through the traced walk, never as data. */ + private addNamedData(moduleFile: string, text: string): void { + const names = new Set(); + for (const token of moduleTokens(text)) + if (token.kind === "string" && !token.value.includes("${")) names.add(basename(token.value)); + if (names.size === 0) return; + const walk = (dir: string): void => { + const entries = trackedAccessibleEntries(dir); + if (entries === null) return; + for (const file of entries.files) { + const path = join(dir, file); + if (!names.has(file) || TRACED.test(file) || path === moduleFile) continue; + this.files.add(path); + } + for (const sub of entries.directories) { + if (sub === "node_modules" || sub === ".git") continue; + walk(join(dir, sub)); + } + }; + walk(dirname(moduleFile)); + } + /** A package: its whole directory, then its dependencies. */ private addPackage(found: string): void { const dir = this.placed(found); diff --git a/packages/compiler/src/node-islands/report.test.ts b/packages/compiler/src/node-islands/report.test.ts index d4221d31f..0b743ddc3 100644 --- a/packages/compiler/src/node-islands/report.test.ts +++ b/packages/compiler/src/node-islands/report.test.ts @@ -20,6 +20,8 @@ function mod(pkg: string | null, file: string, markers: NodeIslandMarker[] = []) packageDir: pkg === null ? null : `${NM}/${pkg}`, packageName: pkg, markers, + cjsReads: [], + stripProblem: null, dropped: false, }; } diff --git a/packages/compiler/src/node-islands/rules.ts b/packages/compiler/src/node-islands/rules.ts index b074ac5bc..a1967e5b5 100644 --- a/packages/compiler/src/node-islands/rules.ts +++ b/packages/compiler/src/node-islands/rules.ts @@ -73,6 +73,7 @@ const REASON_RULES: Record = { "annexb-block-function": "R2.4", "sloppy-this": "R2.4", "builtin-write": "R2.5", + "builtin-patch": "R2.5", proxy: "R2.5", }; diff --git a/packages/compiler/src/node-islands/scan.ts b/packages/compiler/src/node-islands/scan.ts index 70d7355fc..07c43b93e 100644 --- a/packages/compiler/src/node-islands/scan.ts +++ b/packages/compiler/src/node-islands/scan.ts @@ -12,7 +12,8 @@ import { resolveBareModule } from "../frontend/resolve.js"; import { isJsSourceFileName } from "../frontend/tsc-codes.js"; import { isRelativeSpecifier, packageNameOfSpecifier } from "../frontend/workspace-registry.js"; import { moduleSourceCandidates } from "../frontend/module-source-candidates.js"; -import { scanModule, type NodeIslandMarker } from "./detect.js"; +import { scanModuleFacts, type CjsRead, type NodeIslandMarker } from "./detect.js"; +import { stripProblem, type StripProblem } from "./type-strip.js"; import { originOf, packageDirOf, type Origin } from "./origin.js"; export interface ScannedModule { @@ -22,6 +23,11 @@ export interface ScannedModule { packageDir: string | null; packageName: string | null; markers: NodeIslandMarker[]; + /** Members the module reads through a builtin's CommonJS object (H3.4). */ + cjsReads: CjsRead[]; + /** A user TypeScript module with a `builtin-patch` marker that Node + * cannot run with type stripping only (H3.5): it stays native. */ + stripProblem: StripProblem | null; /** True for a module of a package the drop-and-reload loop removed. */ dropped: boolean; } @@ -150,14 +156,24 @@ export function scanFiles( if (origin === "builtin" || origin === "shim") continue; const at = origin === "npm" ? packageDirOf(sf.fileName) : null; const js = isJsSourceFileName(sf.fileName); - let markers = js && (origin === "npm" || opts.userJs) ? scanModule(sf) : []; - if (markers.length > 0 && !sloppyParsable(program, sf)) markers = []; + // A user TypeScript module is scanned only for the builtin-patch marker + // (H3.2): its other markers are JavaScript semantics, and sloppy + // parsing is never required of TypeScript. + const userTs = !js && origin === "user" && !sf.isDeclarationFile && opts.userJs; + let facts: ReturnType = { markers: [], cjsReads: [] }; + if ((js && (origin === "npm" || opts.userJs)) || userTs) facts = scanModuleFacts(sf); + let markers = userTs + ? facts.markers.filter((marker) => marker.reason === "builtin-patch") + : facts.markers; + if (js && markers.length > 0 && !sloppyParsable(program, sf)) markers = []; modules.push({ file: sf.fileName, origin, packageDir: at === null ? null : at.dir, packageName: at === null ? null : at.name, markers, + cjsReads: facts.cjsReads, + stripProblem: userTs && markers.length > 0 ? stripProblem(program, sf) : null, dropped: opts.dropped, }); if (!opts.edges) continue; diff --git a/packages/compiler/src/node-islands/summary.ts b/packages/compiler/src/node-islands/summary.ts index 8108d985c..c73399561 100644 --- a/packages/compiler/src/node-islands/summary.ts +++ b/packages/compiler/src/node-islands/summary.ts @@ -78,9 +78,18 @@ export interface NodeIslandReport { unknownPackages: string[]; } +/** A reason whose report sentence says what it means for the build, in + * addition to the pre-scan construct. */ +const REASON_SENTENCES: Readonly> = { + "builtin-patch": "it patches a builtin module's CommonJS export, which only code in Node can see", +}; + /** "R2.1 constructor functions: 'new Foo()' of a function" or * "SC2012: ". */ export function describe(r: NodeIslandReason): string { + const sentence = REASON_SENTENCES[r.reason]; + if (sentence !== undefined) + return `${r.rule === null ? "" : `${r.rule} `}${r.reason}: ${sentence} (${r.message})`; if (r.source === "pre-scan" && r.rule !== null) return `${r.rule} ${r.message}`; const rule = r.rule === null ? "" : `${r.rule} `; return r.code === null ? `${rule}${r.message}` : `${rule}${r.code}: ${r.message}`; diff --git a/packages/compiler/src/node-islands/type-strip.ts b/packages/compiler/src/node-islands/type-strip.ts new file mode 100644 index 000000000..4e8c61b45 --- /dev/null +++ b/packages/compiler/src/node-islands/type-strip.ts @@ -0,0 +1,181 @@ +/* Node runs TypeScript by erasing types in place ("strip" mode: process. + * features.typescript === "strip" in the embedded libnode). It refuses + * syntax that needs a transform (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX) and it + * resolves relative imports only with their written extension, so a module + * that needs either cannot run in a Node island as written (H3.5). The + * checks are syntactic except the one for a plain `import { T }` of a + * type, which needs the checker: Node keeps that import and fails at load. */ +import type { SrcLoc } from "../ir/ir.js"; +import * as ts from "../frontend/ts7/adapter.js"; + +export interface StripProblem { + /** The construct, as a short phrase for the SC8006 message. */ + construct: string; + loc: SrcLoc; +} + +/** Extensions Node's type-stripping loader resolves for a relative + * specifier. Node requires one for an ES module specifier, written in + * full. */ +const RESOLVED_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"]; + +function locOf(node: ts.Node): SrcLoc { + return { + file: ts.getSourceFileOfNode(node).fileName, + start: ts.tokenStartOfNode(node), + end: node.end, + }; +} + +function isRelativeSpecifier(spec: string): boolean { + return spec.startsWith("./") || spec.startsWith("../"); +} + +function hasResolvedExtension(spec: string): boolean { + const clean = spec.replace(/[?#].*$/, ""); + return RESOLVED_EXTENSIONS.some((extension) => clean.endsWith(extension)); +} + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + return node.modifiers?.nodes.some((modifier) => modifier.kind === kind) ?? false; +} + +/** Whether a namespace/module declaration has runtime values (anything + * besides types and other ambient declarations). */ +function namespaceHasValues(body: ts.Node | undefined): boolean { + if (body === undefined) return false; + if (ts.isModuleDeclaration(body)) return true; + if (!ts.isModuleBlock(body)) return false; + return body.statements.nodes.some((statement) => !isTypeOnlyStatement(statement)); +} + +function isTypeOnlyStatement(statement: ts.Node): boolean { + if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) return true; + if (ts.isModuleDeclaration(statement)) return !namespaceHasValues(statement.body); + return false; +} + +/** The value meaning of an imported name, following alias symbols. */ +function importedValue(checker: ts.TypeChecker, element: ts.ImportSpecifier): boolean | null { + const local = element.name; + let symbol = checker.getSymbolAtLocation(local); + if (symbol === undefined) return null; + const seen = new Set(); + while ((symbol.flags & ts.SymbolFlags.Alias) !== 0 && !seen.has(symbol)) { + seen.add(symbol); + symbol = checker.getAliasedSymbol(symbol); + } + return (symbol.flags & ts.SymbolFlags.Value) !== 0; +} + +/** The first construct in `sf` that Node cannot run with type stripping + * only, or null. Only called for a user TypeScript module the pre-scan + * marked as patching a builtin, so the walk costs nothing elsewhere. */ +export function stripProblem(program: ts.Program, sf: ts.SourceFile): StripProblem | null { + let checker: ts.TypeChecker | null = null; + let found: StripProblem | null = null; + ts.walkPreorder(sf, (node) => { + if (found !== null) return "stop"; + const report = (construct: string): void => { + found = { construct, loc: locOf(node) }; + }; + if (ts.isEnumDeclaration(node)) { + if (!hasModifier(node, ts.SyntaxKind.DeclareKeyword)) report("an 'enum' declaration"); + return undefined; + } + if (ts.isModuleDeclaration(node)) { + if (!hasModifier(node, ts.SyntaxKind.DeclareKeyword) && namespaceHasValues(node.body)) + report("a 'namespace' declaration with values"); + return undefined; + } + if (ts.isConstructorDeclaration(node)) { + for (const parameter of node.parameters.nodes) { + if ( + parameter.modifiers?.nodes.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PublicKeyword || + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword || + modifier.kind === ts.SyntaxKind.ReadonlyKeyword, + ) === true + ) { + report("a constructor parameter property"); + return undefined; + } + } + return undefined; + } + if (ts.isImportEqualsDeclaration(node)) { + report("an 'import = require()' declaration"); + return undefined; + } + if (ts.isExportAssignment(node) && node.isExportEquals === true) { + report("an 'export =' assignment"); + return undefined; + } + if ( + node.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.Decorator) === true + ) { + report("a decorator"); + return undefined; + } + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + report("JSX"); + return undefined; + } + if (ts.isImportDeclaration(node)) { + const spec = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null; + if (spec !== null && isRelativeSpecifier(spec) && !hasResolvedExtension(spec)) { + report(`the relative import '${spec}' without a file extension`); + return undefined; + } + const clause = node.importClause; + if ( + clause === undefined || + clause.phaseModifier === ts.SyntaxKind.TypeKeyword || + clause.namedBindings === undefined || + !ts.isNamedImports(clause.namedBindings) + ) + return undefined; + checker ??= program.getTypeChecker(); + for (const element of clause.namedBindings.elements.nodes) { + if (element.isTypeOnly) continue; + if (importedValue(checker, element) === false) { + found = { + construct: `the import of '${element.name.text}', which is only a type (use \`import type\`)`, + loc: locOf(element), + }; + return "stop"; + } + } + return undefined; + } + if (ts.isExportDeclaration(node)) { + const specifier = node.moduleSpecifier; + if ( + specifier !== undefined && + ts.isStringLiteral(specifier) && + isRelativeSpecifier(specifier.text) && + !hasResolvedExtension(specifier.text) + ) + report(`the relative export from '${specifier.text}' without a file extension`); + return undefined; + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.nodes.length > 0 + ) { + const argument = node.arguments.nodes[0]; + if ( + argument !== undefined && + ts.isStringLiteral(argument) && + isRelativeSpecifier(argument.text) && + !hasResolvedExtension(argument.text) + ) + report(`the relative import() of '${argument.text}' without a file extension`); + } + return undefined; + }); + return found; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-builtin-patch/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-builtin-patch/main.ts.json new file mode 100644 index 000000000..12765200f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6897-node-island-builtin-patch/main.ts.json @@ -0,0 +1,17 @@ +{ + "order": [ + "/tests/corpus/6897-node-island-builtin-patch/loader.ts", + "/tests/corpus/6897-node-island-builtin-patch/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Could not find a declaration file for module 'payload-reader'. '/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/index.js' implicitly has an 'any' type.\n Try `npm i --save-dev @types/payload-reader` if it exists or add a new declaration (.d.ts) file containing `declare module 'payload-reader';`", + "loc": { + "file": "/tests/corpus/6897-node-island-builtin-patch/loader.ts", + "start": 1175, + "end": 1191 + } + } + ] +} diff --git a/tests/corpus/6897-node-island-builtin-patch/fallback/payload.txt b/tests/corpus/6897-node-island-builtin-patch/fallback/payload.txt new file mode 100644 index 000000000..057938d12 --- /dev/null +++ b/tests/corpus/6897-node-island-builtin-patch/fallback/payload.txt @@ -0,0 +1 @@ +from the fallback path diff --git a/tests/corpus/6897-node-island-builtin-patch/loader.ts b/tests/corpus/6897-node-island-builtin-patch/loader.ts new file mode 100644 index 000000000..56ad94ea3 --- /dev/null +++ b/tests/corpus/6897-node-island-builtin-patch/loader.ts @@ -0,0 +1,39 @@ +// Patches the CommonJS fs object while a package that reads its data file +// through require("fs") loads, then restores it. +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const fs = require("fs") as typeof import("fs"); +type ReadFileSync = typeof fs.readFileSync; +const here = dirname(fileURLToPath(import.meta.url)); + +export interface LoadResult { + text: string; + patchedCalls: number; + restored: boolean; +} + +export async function loadPayload(): Promise { + const original: ReadFileSync = fs.readFileSync.bind(fs); + const mutableFs = fs as { readFileSync: ReadFileSync }; + let patchedCalls = 0; + const patched = ((...args: Parameters) => { + patchedCalls++; + const [file, options] = args; + if (typeof file === "string" && file.endsWith("payload.txt") && !fs.existsSync(file)) { + return original(join(here, "fallback", "payload.txt"), options); + } + return original(...args); + }) as ReadFileSync; + mutableFs.readFileSync = patched; + let text: string; + try { + const mod = (await import("payload-reader")) as { default: { payload: string } }; + text = mod.default.payload.trim(); + } finally { + mutableFs.readFileSync = original; + } + return { text, patchedCalls, restored: fs.readFileSync === original }; +} diff --git a/tests/corpus/6897-node-island-builtin-patch/main.ts b/tests/corpus/6897-node-island-builtin-patch/main.ts new file mode 100644 index 000000000..861ddd786 --- /dev/null +++ b/tests/corpus/6897-node-island-builtin-patch/main.ts @@ -0,0 +1,19 @@ +// @requires-libnode +// A module that patches a builtin module's CommonJS export (the photon +// idiom): loader.ts replaces fs.readFileSync while a package loads, so the +// package's own require("fs").readFileSync finds a payload that is not at +// its default path. Because it patches a builtin, loader.ts runs in a Node +// island automatically and shares Node's one fs with the package; the +// entry stays native. Its named import of readFileSync is not patched: +// Node's ES module bindings of builtins do not follow CommonJS patches. +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPayload } from "./loader.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); +const result = await loadPayload(); +console.log("package read:", result.text); +console.log("calls through the patch:", result.patchedCalls); +console.log("restored:", result.restored); +console.log("native read:", readFileSync(join(here, "fallback", "payload.txt"), "utf8").trim()); diff --git a/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/index.js b/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/index.js new file mode 100644 index 000000000..cd25d1010 --- /dev/null +++ b/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/index.js @@ -0,0 +1,5 @@ +// Reads its data file next to itself through the CommonJS fs object, the +// way wasm-bindgen packages read their .wasm file. +const fs = require("fs"); +const path = require("path"); +exports.payload = fs.readFileSync(path.join(__dirname, "payload.txt"), "utf8"); diff --git a/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/package.json b/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/package.json new file mode 100644 index 000000000..78905724c --- /dev/null +++ b/tests/corpus/6897-node-island-builtin-patch/node_modules/payload-reader/package.json @@ -0,0 +1 @@ +{ "name": "payload-reader", "version": "1.0.0", "main": "index.js" } diff --git a/tests/corpus/6897-node-island-builtin-patch/package.json b/tests/corpus/6897-node-island-builtin-patch/package.json new file mode 100644 index 000000000..ebbfc464b --- /dev/null +++ b/tests/corpus/6897-node-island-builtin-patch/package.json @@ -0,0 +1 @@ +{ "name": "builtin-patch-island", "private": true, "type": "module" } diff --git a/tests/harness/corpus-costs/6897-node-island-builtin-patch/main.ts.json b/tests/harness/corpus-costs/6897-node-island-builtin-patch/main.ts.json new file mode 100644 index 000000000..6fb6039dd --- /dev/null +++ b/tests/harness/corpus-costs/6897-node-island-builtin-patch/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2500 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 215d75384..326f6530a 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -12,6 +12,7 @@ import { cpSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, realpathSync, @@ -19,6 +20,7 @@ import { statSync, writeFileSync, } from "node:fs"; +import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { beforeAll, describe, expect, test } from "vitest"; @@ -336,6 +338,170 @@ describe.skipIf(skip)("Node islands in-process", () => { ); expect(conflicting).toEqual([]); }); + + // A module that patches a builtin module's CommonJS object runs in a Node + // island automatically (H3): the patch is meant for code that shares + // Node's builtin, and only Node has that object. + describe("a module that patches a builtin module", () => { + const corpus = join(repoRoot, "tests/corpus/6897-node-island-builtin-patch"); + + /** A scratch copy of the corpus, with main.ts optionally replaced. It + * lives outside node_modules: a package copied below one is a library + * with no declaration file, and its implicit `any` fails the build. */ + function copyCorpus(name: string, main?: string, extra: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), `scriptc-builtin-patch-${name}-`)); + cpSync(corpus, dir, { recursive: true }); + if (main !== undefined) writeFileSync(join(dir, "main.ts"), main); + for (const [file, text] of Object.entries(extra)) writeFileSync(join(dir, file), text); + return dir; + } + + /** A build with a cache of its own: a hit reports the islands but not + * the SC8001 notes, and these tests read the notes. */ + async function buildDir(dir: string, name: string) { + return withFreshBuildCache(() => + compile(join(dir, "main.ts"), { + outPath: join(dir, name), + outDir: dir, + sanitize, + libnode: pack!, + }), + ); + } + + async function withFreshBuildCache(build: () => Promise): Promise { + const cache = mkdtempSync(join(tmpdir(), "scriptc-builtin-patch-cache-")); + const previous = process.env["SCRIPTC_CACHE_DIR"]; + process.env["SCRIPTC_CACHE_DIR"] = cache; + try { + return await build(); + } finally { + if (previous === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; + else process.env["SCRIPTC_CACHE_DIR"] = previous; + rmSync(cache, { recursive: true, force: true }); + } + } + + function messages(result: { diagnostics?: { code: string; message: string }[] }): string[] { + return (result.diagnostics ?? []).map((d) => `${d.code}: ${d.message}`); + } + + test("islands only the patching module, with the builtin-patch reason", async () => { + const dir = copyCorpus("auto"); + const result = await buildDir(dir, "main"); + expect(messages(result)).toEqual([]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.islands?.modules.map((file) => basename(file))).toEqual(["loader.ts"]); + const notes = (result.warnings ?? []).filter( + (d) => d.code === "SC8001" && d.message.includes("loader.ts"), + ); + expect(notes).toHaveLength(1); + expect(notes[0]!.message).toContain("builtin-patch"); + expect(notes[0]!.message).toContain("fs.readFileSync"); + expect(notes[0]!.message).toContain("Node island"); + }, 300_000); + + test("a native read of the patched member is SC8020", async () => { + const dir = copyCorpus( + "read", + `import fs from "node:fs"; +import { loadPayload } from "./loader.ts"; +const result = await loadPayload(); +console.log(result.text, fs.readFileSync("payload.txt", "utf8")); +`, + ); + const result = await buildDir(dir, "main"); + expect(result.ok).toBe(false); + const list = messages(result); + const read = list.find((m) => m.startsWith("SC8020:")); + expect(read).toBeDefined(); + expect(read).toContain("fs.readFileSync"); + expect(read).toContain("loader.ts"); + }, 300_000); + + test("--node-islands=off makes the patch a native refusal again", async () => { + const dir = copyCorpus( + "off", + 'import { patch } from "./loader.ts";\npatch();\nconsole.log("ok");\n', + ); + // The package the corpus loads has no declaration file, and without + // an island its implicit `any` would fail before the patch refusal. + writeFileSync( + join(dir, "loader.ts"), + `import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const fs = require("fs") as typeof import("fs"); +const original = fs.readFileSync.bind(fs); +export function patch(): void { + fs.readFileSync = original as typeof fs.readFileSync; +} +`, + ); + const result = await withFreshBuildCache(() => + compile(join(dir, "main.ts"), { + outPath: join(dir, "main"), + outDir: dir, + sanitize, + libnode: pack!, + nodeIslands: { mode: "off" }, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect( + result.diagnostics.some( + (d) => /^SC(1090|2020)$/.test(d.code) && d.loc.file.endsWith("loader.ts"), + ), + ).toBe(true); + }, 300_000); + + test("an entry module that patches is SC8006", async () => { + const dir = copyCorpus( + "entry", + `import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const fs = require("fs") as typeof import("fs"); +fs.readFileSync = (() => "") as typeof fs.readFileSync; +console.log(fs.existsSync("x")); +`, + ); + const result = await buildDir(dir, "main"); + expect(result.ok).toBe(false); + const entry = messages(result).find( + (m) => m.startsWith("SC8006:") && m.includes("move the patch into a module of its own"), + ); + expect(entry).toBeDefined(); + expect(entry).toContain("fs.readFileSync"); + }); + + test("a TypeScript patcher Node cannot strip is SC8006", async () => { + const dir = copyCorpus( + "enum", + `import { patched } from "./enumpatch.ts"; +console.log(patched); +`, + { + "enumpatch.ts": `import { createRequire } from "node:module"; +enum Kind { + A, +} +const require = createRequire(import.meta.url); +const fs = require("fs") as typeof import("fs"); +fs.readFileSync = (() => "") as typeof fs.readFileSync; +export const patched = Kind.A; +`, + }, + ); + const result = await buildDir(dir, "main"); + expect(result.ok).toBe(false); + const stripped = messages(result).find( + (m) => m.startsWith("SC8006:") && m.includes("Node cannot strip its types"), + ); + expect(stripped).toBeDefined(); + expect(stripped).toContain("enum"); + }); + }); }); // Lowering checks every argument and result type for JSON data; IR output From ef3745ce07edea937c5c0930e887882e6c1de481 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 18:11:42 -0700 Subject: [PATCH 13/44] Number the sloppy island corpus program 8427 `8423-accessor-member-kind-mismatch.ts` already owns 8423 on main. Co-Authored-By: Claude Opus 5.5 --- .../main.js.json | 26 +++++++++---------- .../lookup.js | 0 .../main.js | 0 .../package.json | 0 .../main.js.json | 0 5 files changed, 13 insertions(+), 13 deletions(-) rename packages/compiler/test/ts7/baselines/order-parity/tests/corpus/{8423-sloppy-with-island => 8427-sloppy-with-island}/main.js.json (68%) rename tests/corpus/{8423-sloppy-with-island => 8427-sloppy-with-island}/lookup.js (100%) rename tests/corpus/{8423-sloppy-with-island => 8427-sloppy-with-island}/main.js (100%) rename tests/corpus/{8423-sloppy-with-island => 8427-sloppy-with-island}/package.json (100%) rename tests/harness/corpus-costs/{8423-sloppy-with-island => 8427-sloppy-with-island}/main.js.json (100%) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json similarity index 68% rename from packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json rename to packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json index c6b9ad9a0..cb38c0ce6 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-sloppy-with-island/main.js.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json @@ -1,14 +1,14 @@ { "order": [ - "/tests/corpus/8423-sloppy-with-island/lookup.js", - "/tests/corpus/8423-sloppy-with-island/main.js" + "/tests/corpus/8427-sloppy-with-island/lookup.js", + "/tests/corpus/8427-sloppy-with-island/main.js" ], "diags": [ { "code": "SC0001", "message": "'with' statements are not allowed in strict mode.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 194, "end": 198 } @@ -17,7 +17,7 @@ "code": "SC0001", "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 194, "end": 207 } @@ -26,7 +26,7 @@ "code": "SC0001", "message": "Cannot find name 'last'.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 252, "end": 256 } @@ -35,7 +35,7 @@ "code": "SC0001", "message": "'with' statements are not allowed in strict mode.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 295, "end": 299 } @@ -44,7 +44,7 @@ "code": "SC0001", "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 295, "end": 308 } @@ -53,7 +53,7 @@ "code": "SC0001", "message": "'with' statements are not allowed in strict mode.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 422, "end": 426 } @@ -62,7 +62,7 @@ "code": "SC0001", "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 422, "end": 447 } @@ -71,7 +71,7 @@ "code": "SC0001", "message": "'with' statements are not allowed in strict mode.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 528, "end": 532 } @@ -80,7 +80,7 @@ "code": "SC0001", "message": "The 'with' statement is not supported. All symbols in a 'with' block will have type 'any'.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 528, "end": 541 } @@ -89,7 +89,7 @@ "code": "SC0001", "message": "'with' statements are not allowed in strict mode.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 548, "end": 552 } @@ -98,7 +98,7 @@ "code": "SC0001", "message": "Cannot find name 'inner'.", "loc": { - "file": "/tests/corpus/8423-sloppy-with-island/lookup.js", + "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", "start": 576, "end": 581 } diff --git a/tests/corpus/8423-sloppy-with-island/lookup.js b/tests/corpus/8427-sloppy-with-island/lookup.js similarity index 100% rename from tests/corpus/8423-sloppy-with-island/lookup.js rename to tests/corpus/8427-sloppy-with-island/lookup.js diff --git a/tests/corpus/8423-sloppy-with-island/main.js b/tests/corpus/8427-sloppy-with-island/main.js similarity index 100% rename from tests/corpus/8423-sloppy-with-island/main.js rename to tests/corpus/8427-sloppy-with-island/main.js diff --git a/tests/corpus/8423-sloppy-with-island/package.json b/tests/corpus/8427-sloppy-with-island/package.json similarity index 100% rename from tests/corpus/8423-sloppy-with-island/package.json rename to tests/corpus/8427-sloppy-with-island/package.json diff --git a/tests/harness/corpus-costs/8423-sloppy-with-island/main.js.json b/tests/harness/corpus-costs/8427-sloppy-with-island/main.js.json similarity index 100% rename from tests/harness/corpus-costs/8423-sloppy-with-island/main.js.json rename to tests/harness/corpus-costs/8427-sloppy-with-island/main.js.json From 52ef883a48e3032709f905e96bf3c26a10495dc6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:10:16 -0700 Subject: [PATCH 14/44] Re-export island package names from native modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native module that re-exports an island package's names (`export { Type, Static } from "typebox"`, pi's ai/src/index.ts) was refused with SC8010: the re-export arm of the island import collection reported every export declaration from an island package as a boundary, so an island value in pi-ai's public API had no way to be written. The barrel is now a module like any other: it loads the island package at its own %init position (nodeIsland.defer, exactly as an import does), snapshots the CommonJS named exports it re-exports, and registers each re-exported name against the island's own export symbol. A name imported from the barrel reaches that symbol through the checker's alias chain (islandReexportOf), so islandBindingOf binds it as the island value it is — including through a second barrel (`export { Num as NumberSchema } from "./barrel.ts"`), since every re-exporting module registers the symbol its chain ends on. A namespace or star re-export has no name of its own to bind and stays refused — and now actually reaches that refusal: the npm-import collection skipped a star or namespace re-export before the island arm ran, so the statement was silently dropped instead of refused. The guard routes it to the island boundary refusal (a type-only star re-export stays erased like a type-only named one), and the path-mapped re-export case in node-island.test.ts pins that refusal with a star re-export rather than the named one this commit makes work. Test: tests/corpus/6898-node-island-reexport (a two-step barrel, differential to Node, plain and sanitized). Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 15 +- .../frontend/lowering/lower-node-island.ts | 136 ++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 8 +- .../6898-node-island-reexport/main.ts.json | 8 ++ .../6898-node-island-reexport/barrel.ts | 5 + .../6898-node-island-reexport/barrel2.ts | 3 + .../corpus/6898-node-island-reexport/main.ts | 18 +++ .../node_modules/schemakit/index.d.ts | 27 ++++ .../node_modules/schemakit/index.js | 37 +++++ .../node_modules/schemakit/package.json | 1 + .../6898-node-island-reexport/package.json | 1 + .../node-island/path-mapped-reexport.ts | 10 +- .../6898-node-island-reexport/main.ts.json | 1 + tests/harness/node-island.test.ts | 2 +- 14 files changed, 240 insertions(+), 32 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json create mode 100644 tests/corpus/6898-node-island-reexport/barrel.ts create mode 100644 tests/corpus/6898-node-island-reexport/barrel2.ts create mode 100644 tests/corpus/6898-node-island-reexport/main.ts create mode 100644 tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts create mode 100644 tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.js create mode 100644 tests/corpus/6898-node-island-reexport/node_modules/schemakit/package.json create mode 100644 tests/corpus/6898-node-island-reexport/package.json create mode 100644 tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 173b44db4..82a1884bd 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -646,8 +646,21 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { if ( reexport === null && (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) - ) + ) { + // A star or namespace re-export (`export * from "pkg"`) names + // nothing the re-exporting module could bind: an island package's + // is refused at the boundary instead of silently dropped. Any + // other module keeps the previous skip, and a type-only one is + // erased like the type-only named re-export above. + if ( + ts.isExportDeclaration(stmt) && + !stmt.isTypeOnly && + stmt.moduleSpecifier !== undefined && + ts.isStringLiteral(stmt.moduleSpecifier) + ) + collectIslandImport(lowerer, fp.sf, stmt, stmt.moduleSpecifier.text); continue; + } const specNode = ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt) ? stmt.moduleSpecifier diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index ffefc4e15..a54681b73 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -404,9 +404,43 @@ export function collectIslandImport( const path = islandModuleFile(islands, sf.fileName, spec, "import"); if (path === null) return false; if (ts.isExportDeclaration(stmt)) { - lowerer.pushDiag( - nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)), - ); + // `export { Type } from "typebox"`: the re-exporting module loads the + // island module and every name it re-exports binds the island's own + // export, so importers of this module reach it through the alias chain + // (islandBindingOf). A namespace or star re-export has no name to bind. + const clause = stmt.exportClause; + if (stmt.isTypeOnly || clause === undefined || !ts.isNamedExports(clause)) { + lowerer.pushDiag( + nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)), + ); + return true; + } + lowerer.usesNodeIsland = true; + const exportLoc = locOf(stmt); + const exported = islandModuleGlobal(lowerer, path); + const initActions = lowerer.jsonInitActions.get(sf) ?? []; + lowerer.jsonInitActions.set(sf, initActions); + initActions.push({ + kind: "assign", + localId: exported.global.id, + value: deferIslandModule(lowerer, path, exportLoc), + loc: exportLoc, + }); + for (const el of clause.elements.nodes) { + if (el.isTypeOnly) continue; + const name = el.propertyName?.text ?? el.name.text; + // CommonJS named exports are the namespace's snapshot (below). + if (!exported.esm && name !== "default") + initActions.push(islandSnapshotAssign(lowerer, exported, name, exportLoc)); + registerIslandReexport( + lowerer, + stmt, + el, + name === "default" + ? { kind: "default", module: exported } + : { kind: "named", module: exported, name }, + ); + } return true; } lowerer.usesNodeIsland = true; @@ -448,31 +482,36 @@ export function collectIslandImport( return undefined; }); } - { - for (const name of snapshotNames) { - if (name === "default") continue; - let g = binding.snapshots.get(name); - if (g === undefined) { - g = { - id: `%g.island.${lowerer.globalsList.length}`, - name, - type: DYN, - mutable: true, - }; - lowerer.globalsList.push(g); - binding.snapshots.set(name, g); - } - actions.push({ - kind: "assign", - localId: g.id, - value: islandGet(varRef(binding.global.id, DYN, loc), name, loc), - loc, - }); - } + for (const name of snapshotNames) { + if (name === "default") continue; + actions.push(islandSnapshotAssign(lowerer, binding, name, loc)); } return true; } +/** A CommonJS island name's snapshot global and the %init action that + * assigns it once the module evaluated (Node's namespace snapshots named + * exports). */ +function islandSnapshotAssign( + lowerer: Lowerer, + binding: IslandModuleBinding, + name: string, + loc: SrcLoc, +): IrStmt { + let g = binding.snapshots.get(name); + if (g === undefined) { + g = { id: `%g.island.${lowerer.globalsList.length}`, name, type: DYN, mutable: true }; + lowerer.globalsList.push(g); + binding.snapshots.set(name, g); + } + return { + kind: "assign", + localId: g.id, + value: islandGet(varRef(binding.global.id, DYN, loc), name, loc), + loc, + }; +} + function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { return { kind: "libCall", @@ -483,12 +522,55 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { }; } -type IslandBinding = +export type IslandBinding = | { kind: "required"; module: IslandModuleBinding } | { kind: "namespace"; module: IslandModuleBinding } | { kind: "default"; module: IslandModuleBinding } | { kind: "named"; module: IslandModuleBinding; name: string }; +/** Records that `specifier`'s exported name binds `binding`: importers of + * the re-exporting module reach the island's own export through the alias + * chain, whatever they spell it (`export { Num as NumberSchema }`). The + * island's export symbol is the key a chain ends on, so it is registered + * beside the specifier's own symbol. */ +function registerIslandReexport( + lowerer: Lowerer, + stmt: ts.ExportDeclaration, + specifier: ts.ExportSpecifier, + binding: IslandBinding, +): void { + const named = lowerer.checker.getSymbolAtLocation(specifier.name); + if (named !== undefined) lowerer.islandReexports.set(named, binding); + const moduleSpecifier = stmt.moduleSpecifier; + if (moduleSpecifier === undefined) return; + const moduleSymbol = lowerer.checker.getSymbolAtLocation(moduleSpecifier); + if (moduleSymbol === undefined) return; + const exported = lowerer.checker.getExportsOfModule(moduleSymbol); + const found = exported.find( + (s) => ts.symbolName(s) === (specifier.propertyName?.text ?? specifier.name.text), + ); + if (found === undefined) return; + lowerer.islandReexports.set(found, binding); + if ((found.flags & ts.SymbolFlags.Alias) !== 0) + lowerer.islandReexports.set(lowerer.checker.getAliasedSymbol(found), binding); +} + +/** The island binding an alias chain reaches, or null: a name re-exported + * by a native module resolves to the island's export symbol itself. */ +function islandReexportOf(lowerer: Lowerer, symbol: ts.Symbol | undefined): IslandBinding | null { + if (symbol === undefined || lowerer.islandReexports.size === 0) return null; + const seen = new Set(); + let s: ts.Symbol | undefined = symbol; + while (s !== undefined && !seen.has(s)) { + seen.add(s); + const bound = lowerer.islandReexports.get(s); + if (bound !== undefined) return bound; + if ((s.flags & ts.SymbolFlags.Alias) === 0) return null; + s = lowerer.checker.getAliasedSymbol(s); + } + return null; +} + function moduleSpecifierOf(decl: ts.Node | undefined): string | null { return ts.isImportDeclaration(decl) && ts.isStringLiteral(decl.moduleSpecifier) ? decl.moduleSpecifier.text @@ -506,6 +588,10 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const islands = lowerer.nodeIslands; if (islands === null) return null; const symbol = lowerer.checker.getSymbolAtLocation(ident); + // A name a native module re-exports from an island package binds the + // island's own export: the import's alias chain reaches it. + const reexport = islandReexportOf(lowerer, symbol); + if (reexport !== null) return reexport; const decl = symbol ? symbol.declarations[0] : undefined; if (decl === undefined) return null; let importDecl: ts.Node | undefined; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ef9d5c31c..43822d695 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -58,7 +58,7 @@ import { setActiveNodeIslands, type NodeIslandSet, } from "../../node-islands/boundary.js"; -import type { IslandModuleBinding } from "./lower-node-island.js"; +import type { IslandBinding, IslandModuleBinding } from "./lower-node-island.js"; import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; import { needsGeneralNodeStreams, @@ -3758,6 +3758,9 @@ export class Lowerer { readonly nodeIslandLoads: { literal: IrExpr & { kind: "strLit" }; path: string }[] = []; /** Island handle bindings by symbol (lower-node-island.ts). */ readonly islandHandleBindings = new Map(); + /** Names a native module re-exports from an island package, by the + * symbol an alias chain reaches (lower-node-island.ts). */ + readonly islandReexports = new Map(); /** `instanceof` expressions the island lowering already handles. */ readonly islandInstanceofHandled = new Set(); /** NI-6: the modules shared with Node islands (lower-shared-modules.ts), @@ -4549,6 +4552,9 @@ export class Lowerer { // NI-5: whether a symbol names an island handle binding depends only on // its declaration, so the answer is a memo keyed by symbol. "islandHandleBindings", + // The island names native modules re-export: a pure function of the + // program's modules, collected before lowering begins. + "islandReexports", // The modules shared with the islands (NI-6): a pure function of the // program's modules, computed once, and the TDZ flags its plan gives a // statement. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json new file mode 100644 index 000000000..783c22cfe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6898-node-island-reexport/barrel.ts", + "/tests/corpus/6898-node-island-reexport/barrel2.ts", + "/tests/corpus/6898-node-island-reexport/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6898-node-island-reexport/barrel.ts b/tests/corpus/6898-node-island-reexport/barrel.ts new file mode 100644 index 000000000..c967e1faa --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/barrel.ts @@ -0,0 +1,5 @@ +// A native module re-exports an island package's values: the barrel loads +// the island module where it evaluates and imports of the barrel bind the +// island module's own exports, not a copy of them. +export { Arr, Num, Str, check } from "schemakit"; +export type { TArray, TNumber, TSchema } from "schemakit"; diff --git a/tests/corpus/6898-node-island-reexport/barrel2.ts b/tests/corpus/6898-node-island-reexport/barrel2.ts new file mode 100644 index 000000000..431d4244e --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/barrel2.ts @@ -0,0 +1,3 @@ +// A second barrel renames one of the island's exports: the binding follows +// the alias chain back to the island's own export. +export { Num as NumberSchema } from "./barrel.ts"; diff --git a/tests/corpus/6898-node-island-reexport/main.ts b/tests/corpus/6898-node-island-reexport/main.ts new file mode 100644 index 000000000..f2533c8f7 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/main.ts @@ -0,0 +1,18 @@ +// @requires-libnode +// A native module re-exports an island package's names (`export … from +// "pkg"`): the barrel evaluates the island module at its own position and +// importers of the barrel call the island's exports in Node exactly as a +// direct import does. The names are the island's own values, so two paths +// to one export are the same function. +import { Arr, Num, Str, check } from "./barrel.ts"; +import { NumberSchema } from "./barrel2.ts"; + +const numbers = Arr(Num()); +const names = Arr(Str()); +console.log(numbers.kind, numbers.describe()); +console.log(names.kind, names.describe()); + +console.log(check(numbers, [1, 2, 3]), check(numbers, [1, "2"]), check(names, ["a"]), check(names, [0])); + +const alias = NumberSchema(); +console.log(alias.kind, alias.describe(), Num === NumberSchema); diff --git a/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts @@ -0,0 +1,27 @@ +// Types the package only declares: its JavaScript has no interfaces and no +// conditional aliases, so code typed from the JavaScript sees `any` here. +export interface TSchema { + readonly kind: string; + describe(): string; +} +export interface TNumber extends TSchema { + readonly kind: "number"; +} +export interface TString extends TSchema { + readonly kind: "string"; +} +export interface TArray extends TSchema { + readonly kind: "array"; + readonly items: T; +} +export type Static = T extends TNumber + ? number + : T extends TString + ? string + : T extends TArray + ? Static[] + : unknown; +export declare function Num(): TNumber; +export declare function Str(): TString; +export declare function Arr(items: T): TArray; +export declare function check(schema: T, value: unknown): value is Static; diff --git a/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.js b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.js @@ -0,0 +1,37 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings +// and a constructor function with prototype methods. scriptc compiles it +// natively only with runtime fences, so it runs in a Node island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Num: () => Num, + Str: () => Str, + Arr: () => Arr, + check: () => check, +}); +module.exports = src_exports; +function Schema(kind, items) { + this.kind = kind; + if (items !== undefined) this.items = items; +} +Schema.prototype.describe = function () { + return this.items === undefined ? this.kind : this.kind + "<" + this.items.describe() + ">"; +}; +function Num() { + return new Schema("number"); +} +function Str() { + return new Schema("string"); +} +function Arr(items) { + return new Schema("array", items); +} +function check(schema, value) { + if (schema.kind === "array") return Array.isArray(value) && value.every((v) => check(schema.items, v)); + return typeof value === schema.kind; +} +0 && (module.exports = { Num, Str, Arr, check }); diff --git a/tests/corpus/6898-node-island-reexport/node_modules/schemakit/package.json b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/package.json @@ -0,0 +1 @@ +{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6898-node-island-reexport/package.json b/tests/corpus/6898-node-island-reexport/package.json new file mode 100644 index 000000000..beaba8ad6 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/package.json @@ -0,0 +1 @@ +{ "name": "island-reexport", "private": true, "type": "module", "scriptc": { "packages": { "schemakit": "node" } } } diff --git a/tests/fixtures/node-island/path-mapped-reexport.ts b/tests/fixtures/node-island/path-mapped-reexport.ts index 7bb102298..6f80b98ff 100644 --- a/tests/fixtures/node-island/path-mapped-reexport.ts +++ b/tests/fixtures/node-island/path-mapped-reexport.ts @@ -1,7 +1,9 @@ // Re-exports from an island package that tsconfig `paths` maps to its -// installed directory: the type-only one is erased, the value one reports -// the island boundary, and the package's declarations never compile as a -// program module. The test builds it beside corpus 6839's pathkit package. +// installed directory: the type-only one is erased, the star one reports +// the island boundary (a star re-export names nothing the re-exporting +// module could bind; a named one is supported, corpus 6898), and the +// package's declarations never compile as a program module. The test +// builds it beside corpus 6839's pathkit package. export type { Route } from "pathkit"; -export { route } from "pathkit"; +export * from "pathkit"; console.log("loaded"); diff --git a/tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json b/tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json new file mode 100644 index 000000000..e75007ac8 --- /dev/null +++ b/tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 34000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 215d75384..3daac0f81 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -381,7 +381,7 @@ test("a failed build reports the island build's errors", async () => { }, 300_000); // Needs no libnode pack: the build fails before linking. -test("re-exporting a path-mapped island package reports the island boundary", async () => { +test("a star re-export of a path-mapped island package reports the island boundary", async () => { const dir = join(outDir, "path-mapped-reexport"); rmSync(dir, { recursive: true, force: true }); const corpus = join(repoRoot, "tests/corpus/6839-node-island-path-mapped"); From ea9b70d1ad5d122a228f20676cefe477cd3abaa1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:10:17 -0700 Subject: [PATCH 15/44] Cross island records of intersection types out as copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An island export whose declared type is an INTERSECTION of record types — typebox's `Type.Unsafe<{[K in "fg" | "bg"]: V} & {[K in "accent"]?: V}>`, the mapped halves of pi's ThemeColorValues read off the island's theme schema — was refused with SC8010 ("an island value of type … only primitives and strings are copied"). The crossing classified values with exportedByReference, which requires the object flag, so an intersection of records looked like a value with no native representation even though mapTypeOf maps it to the record its parts make. The copy decision for a value crossing OUT is now copiedRecordType: the object types exportedByReference names, and intersections of them (nullish arms aside). Such a value crosses as the deep checked copy the record path already uses, so a field that contradicts the declaration throws `TypeError: expected string at $.fg, got number` instead of entering natively typed memory (types don't lie). Copy, not view, for the reason 9a8e964 chose for arrays: nothing native can alias the island's object. Test: tests/corpus/6899-node-island-unsafe-record (differential to Node, plain and sanitized) and the lying-record case in tests/harness/node-island-handles.test.ts. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/lower-node-island.ts | 21 ++++++++++++++- .../main.ts.json | 6 +++++ .../6899-node-island-unsafe-record/main.ts | 21 +++++++++++++++ .../node_modules/recordkit/index.d.ts | 12 +++++++++ .../node_modules/recordkit/index.js | 9 +++++++ .../node_modules/recordkit/package.json | 1 + .../package.json | 1 + .../fixtures/node-island/unsafe-record-lie.ts | 12 +++++++++ .../main.ts.json | 1 + tests/harness/node-island-handles.test.ts | 26 +++++++++++++++++++ 10 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json create mode 100644 tests/corpus/6899-node-island-unsafe-record/main.ts create mode 100644 tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts create mode 100644 tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js create mode 100644 tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json create mode 100644 tests/corpus/6899-node-island-unsafe-record/package.json create mode 100644 tests/fixtures/node-island/unsafe-record-lie.ts create mode 100644 tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index a54681b73..053e5039f 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -739,7 +739,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr }; return lowerer.coerceToExpected(copy, mapped); } - if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType)) + if (!copiedType(lowerer, mapped) && copiedRecordType(lowerer, tsType)) return lowerer.coerceToExpected(raw, mapped); if (!copiedType(lowerer, mapped)) boundaryFail( @@ -1245,6 +1245,25 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean { return true; } +/** A record type an island object crosses OUT into as a deep checked + * copy: the object types exportedByReference names, and intersections of + * them — a mapped pair (`{[K in Fg]: V} & {[K in Bg]?: V}`) or a merged + * generic record (`Omit & Extras`) is one record, so the + * island's object converts into the native one the same way a plain + * record's does. An intersection with a primitive arm (`string & {brand}`) + * is a primitive value, which copies as its arm. */ +function copiedRecordType(lowerer: Lowerer, type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Intersection) !== 0) + return ts + .constituentTypes(type) + .every( + (t) => + (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || + copiedRecordType(lowerer, t), + ); + return exportedByReference(lowerer, type); +} + /** A fresh object or array literal crosses as a copy (a plain dyn object * or array the runtime copies into V8): nothing native can alias it. Its * members cross by the same rules (toIsland). */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json new file mode 100644 index 000000000..f1fc0a68d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6899-node-island-unsafe-record/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6899-node-island-unsafe-record/main.ts b/tests/corpus/6899-node-island-unsafe-record/main.ts new file mode 100644 index 000000000..8eb8d894e --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/main.ts @@ -0,0 +1,21 @@ +// @requires-libnode +// A record the island hands back (`unsafe` declares the caller's own +// type, like typebox's `Type.Unsafe`) crosses out as a deep checked +// copy: the merged type is a required half and an optional half, and the +// native value carries every field the declaration promises. +import { unsafe } from "recordkit"; + +/** A mapped pair of halves — one record, spelled as an intersection. */ +type ColorValues = { [K in "fg" | "bg"]: Value } & { [K in "accent"]?: Value }; + +const colors = unsafe>({ fg: "red", bg: "blue" }); +console.log(colors.fg, colors.bg, colors.accent === undefined, Object.keys(colors).join(",")); + +const levels = unsafe>({ fg: 1, bg: 2, accent: 3 }); +console.log(levels.fg + levels.bg, levels.accent, Object.keys(levels).join(",")); + +/** Native code reads the copy's fields by their declared types. */ +function describe(values: ColorValues): string { + return `${values.fg}/${values.bg}/${values.accent ?? "-"}`; +} +console.log(describe(colors), describe({ fg: "green", bg: "black" })); diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts new file mode 100644 index 000000000..ed760c9d1 --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts @@ -0,0 +1,12 @@ +export interface Schema { + readonly kind: string; + describe(): string; +} +/** Declares the type of the value it is given, like typebox's + * `Type.Unsafe`: the island hands back the caller's own type, so the + * caller's declaration must describe what the island really returns. */ +export declare function unsafe(value: T): T; +/** A schema over the properties record it is given, like `Type.Object`. */ +export declare function object(properties: T): Schema & { readonly properties: T }; +/** The sorted own keys of a value, joined. */ +export declare function keys(value: unknown): string; diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js new file mode 100644 index 000000000..4dec16d1f --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js @@ -0,0 +1,9 @@ +"use strict"; + +exports.unsafe = (value) => value; +exports.object = (properties) => ({ + kind: "object", + describe: () => "object " + Object.keys(properties).sort().join(","), + properties, +}); +exports.keys = (value) => Object.keys(value).sort().join(","); diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json new file mode 100644 index 000000000..ef697c47c --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json @@ -0,0 +1 @@ +{ "name": "recordkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6899-node-island-unsafe-record/package.json b/tests/corpus/6899-node-island-unsafe-record/package.json new file mode 100644 index 000000000..0b88adb53 --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/package.json @@ -0,0 +1 @@ +{ "name": "node-island-unsafe-record", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } } diff --git a/tests/fixtures/node-island/unsafe-record-lie.ts b/tests/fixtures/node-island/unsafe-record-lie.ts new file mode 100644 index 000000000..057724d71 --- /dev/null +++ b/tests/fixtures/node-island/unsafe-record-lie.ts @@ -0,0 +1,12 @@ +// `unsafe` declares the caller's own type and hands back the value it +// was given: the record crossing out is a deep CHECKED copy, so a field +// that contradicts the declaration throws a TypeError instead of entering a +// native record (a corpus program cannot pin this: Node prints the field). +import { unsafe } from "recordkit"; + +type ColorValues = { [K in "fg" | "bg"]: string } & { [K in "accent"]?: string }; + +const raw: unknown = JSON.parse('{"fg":1,"bg":"blue"}'); +console.log("before"); +const colors = unsafe(raw as ColorValues); +console.log("after", colors.fg); diff --git a/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json b/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json new file mode 100644 index 000000000..f350af81d --- /dev/null +++ b/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 33000 } diff --git a/tests/harness/node-island-handles.test.ts b/tests/harness/node-island-handles.test.ts index 650036bf4..5632a18ef 100644 --- a/tests/harness/node-island-handles.test.ts +++ b/tests/harness/node-island-handles.test.ts @@ -210,6 +210,32 @@ describe.skipIf(skip)("Node island handles", () => { ); }, 300_000); + test("an island record that contradicts its declaration throws at the crossing", async () => { + // A record the island hands back crosses as a deep checked copy: + // `unsafe` declares the caller's own record type and hands back a + // JSON object whose `fg` is a number, so the copy's conversion throws + // before a number reaches a native string field. + const root = join(outDir, "unsafe-record-lie"); + rmSync(root, { recursive: true, force: true }); + cpSync(join(repoRoot, "tests/corpus/6899-node-island-unsafe-record"), root, { + recursive: true, + }); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/unsafe-record-lie.ts"), + join(root, "main.ts"), + ); + const result = await compile(join(root, "main.ts"), { + outPath: join(root, "main"), + outDir: root, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(await refusal(result.binaryPath)).toContain( + "TypeError: expected string at $.fg, got number", + ); + }, 300_000); + test("an island package ships with the program", async () => { const root = join(outDir, "ships"); const src = join(root, "src"); From 6ba4e6e52adeac0f9f1f72a36352ccbf80dd50dd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:10:18 -0700 Subject: [PATCH 16/44] Export merged record types into islands by reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merged generic record crossing INTO an island — typebox's `Type.Object(props)` with pi's `Omit & Extras` (compat-schema.ts) — was refused with SC8010 ("a native object would cross as a copy"). exportedByReference, which decides that a native record crosses as itself, requires the object flag, so an intersection of records fell through its union test and was refused. An intersection of the types exportedByReference names is now one of them: a merged generic record is one native object, and the island reads the program's own object rather than a copy of it, so an island call over `props` and over `schema.properties` sees the same object Node does. An intersection with a primitive arm (`string & {brand}`) stays a primitive and is copied. Test: tests/corpus/6900-node-island-merged-record-in (differential to Node, plain and sanitized). Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/lower-node-island.ts | 14 ++++++++- .../main.ts.json | 6 ++++ .../6900-node-island-merged-record-in/main.ts | 31 +++++++++++++++++++ .../node_modules/recordkit/index.d.ts | 12 +++++++ .../node_modules/recordkit/index.js | 9 ++++++ .../node_modules/recordkit/package.json | 1 + .../package.json | 1 + .../main.ts.json | 1 + 8 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json create mode 100644 tests/corpus/6900-node-island-merged-record-in/main.ts create mode 100644 tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts create mode 100644 tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js create mode 100644 tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json create mode 100644 tests/corpus/6900-node-island-merged-record-in/package.json create mode 100644 tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 053e5039f..76456833e 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -1226,7 +1226,11 @@ export function classConstructHelper( /** Native values NI-5 exports into the island by reference: functions, * promises, and object types other than arrays, tuples, Maps and Sets * (records, interfaces, class instances). A union qualifies when every - * arm does or is copied. */ + * arm does or is copied, and an intersection of them qualifies: a merged + * generic record (`Omit & Extras`, the `Type.Object(props)` + * shape) is one native object, and the island reads the program's own + * object, not a copy of it. An intersection with a primitive arm + * (`string & {brand}`) is a primitive value, which the runtime copies. */ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean { const checker = lowerer.checker; if (type.isUnionType()) @@ -1237,6 +1241,14 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean { (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || exportedByReference(lowerer, t), ); + if ((type.flags & ts.TypeFlags.Intersection) !== 0) + return ts + .constituentTypes(type) + .every( + (t) => + (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || + exportedByReference(lowerer, t), + ); if ((type.flags & ts.TypeFlags.Object) === 0) return false; // Arrays and tuples cross as Proxies over the native array (NI-5b). void checker; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json new file mode 100644 index 000000000..4818b61b1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6900-node-island-merged-record-in/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6900-node-island-merged-record-in/main.ts b/tests/corpus/6900-node-island-merged-record-in/main.ts new file mode 100644 index 000000000..51e286916 --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/main.ts @@ -0,0 +1,31 @@ +// @requires-libnode +// A merged generic record crossing INTO the island (`object(properties)` +// is `Type.Object(props)`): the merged type is an intersection — the base's +// fields without one, plus the overrides — and the island reads the +// program's own object, not a copy of it. +import { keys, object } from "recordkit"; + +interface ProviderBase { + name: string; + retries: number; +} + +interface ProviderOverrides { + stream: boolean; +} + +/** A merged record spelled as an intersection of a mapped type and an interface. */ +type MergedProvider = Omit & ProviderOverrides; + +function merged(name: string, stream: boolean): MergedProvider { + return { name, stream }; +} + +const props: MergedProvider = merged("openai", true); +const schema = object(props); +console.log(schema.kind, schema.describe()); +console.log(keys(props), keys(schema.properties)); +console.log(schema.properties.name, schema.properties.stream); + +const inline = object({ name: "anthropic", stream: false }); +console.log(inline.kind, inline.describe(), inline.properties.name); diff --git a/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts new file mode 100644 index 000000000..ed760c9d1 --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts @@ -0,0 +1,12 @@ +export interface Schema { + readonly kind: string; + describe(): string; +} +/** Declares the type of the value it is given, like typebox's + * `Type.Unsafe`: the island hands back the caller's own type, so the + * caller's declaration must describe what the island really returns. */ +export declare function unsafe(value: T): T; +/** A schema over the properties record it is given, like `Type.Object`. */ +export declare function object(properties: T): Schema & { readonly properties: T }; +/** The sorted own keys of a value, joined. */ +export declare function keys(value: unknown): string; diff --git a/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js new file mode 100644 index 000000000..4dec16d1f --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js @@ -0,0 +1,9 @@ +"use strict"; + +exports.unsafe = (value) => value; +exports.object = (properties) => ({ + kind: "object", + describe: () => "object " + Object.keys(properties).sort().join(","), + properties, +}); +exports.keys = (value) => Object.keys(value).sort().join(","); diff --git a/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json new file mode 100644 index 000000000..ef697c47c --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json @@ -0,0 +1 @@ +{ "name": "recordkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6900-node-island-merged-record-in/package.json b/tests/corpus/6900-node-island-merged-record-in/package.json new file mode 100644 index 000000000..1f5c5ec87 --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/package.json @@ -0,0 +1 @@ +{ "name": "node-island-merged-record-in", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } } diff --git a/tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json b/tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json new file mode 100644 index 000000000..b7c2b4e52 --- /dev/null +++ b/tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 34000 } From d2e3690bc3d3d35544e093705814207a7da24498 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:10:19 -0700 Subject: [PATCH 17/44] Keep island arrays of intersection types as handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Choice[] & GenChoice[]`, an island array extended by the caller's own choice type (pi's openrouter images: `imageResponse.choices`), was refused with SC8010. The intersection has no native IR type of its own — mapTypeOf returns null, or its arms map to different array types — so the crossing failed before the container rules that keep an array the island does not declare a handle. An intersection of containers is a container: it is a value of each arm, and where the program only uses it through island operations (member reads, an island call, an island identity test, a handle binding) it now stays a handle, exactly as an undeclared array does. Elements and fields the declaration does name cross back out on their own terms, so an element's `images` array copies, and any other use still refuses rather than inventing a native layout. Test: tests/corpus/6901-node-island-array-intersection (both shapes, the island-declared arm and the caller's own arm, differential to Node, plain and sanitized). Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/lower-node-island.ts | 20 ++++++++++++++-- .../main.ts.json | 6 +++++ .../main.ts | 24 +++++++++++++++++++ .../node_modules/choicekit/index.d.ts | 18 ++++++++++++++ .../node_modules/choicekit/index.js | 10 ++++++++ .../node_modules/choicekit/package.json | 1 + .../package.json | 1 + .../main.ts.json | 1 + 8 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6901-node-island-array-intersection/main.ts.json create mode 100644 tests/corpus/6901-node-island-array-intersection/main.ts create mode 100644 tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.d.ts create mode 100644 tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.js create mode 100644 tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/package.json create mode 100644 tests/corpus/6901-node-island-array-intersection/package.json create mode 100644 tests/harness/corpus-costs/6901-node-island-array-intersection/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 76456833e..c36dc345f 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -681,12 +681,19 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr if ((tsType.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 && isDiscarded(node)) return raw; const mapped = lowerer.mapTypeOf(tsType); - if (mapped === null) + if (mapped === null) { + // An intersection of containers (`Choice[] & GenChoice[]`: the + // island's array extended by the caller's own type) has no native + // representation of its own. Where the program only uses it through + // island operations it stays a handle, like an array the island does + // not declare. + if (containerType(lowerer, tsType) && handleUseOk(lowerer, node)) return raw; boundaryFail( lowerer, `an island value of type '${lowerer.checker.typeToString(tsType)}'`, node, ); + } if (mapped.kind === "dyn") return raw; if (mapped.kind === "void") return raw; // An island promise is the same promise on both sides (the runtime keeps @@ -887,7 +894,8 @@ function builtinHandleClass(type: ts.Type): boolean { /** Arrays, tuples, Maps and Sets (every arm of a union, nullish arms * aside), and the builtin classes of builtinHandleClass: the object types - * an island hands back without declaring them. */ + * an island hands back without declaring them. An intersection of those + * (`Choice[] & GenChoice[]`) is a container too: it is a value of each. */ function containerType(lowerer: Lowerer, type: ts.Type): boolean { if (type.isUnionType()) return ts @@ -897,6 +905,14 @@ function containerType(lowerer: Lowerer, type: ts.Type): boolean { (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || containerType(lowerer, t), ); + if ((type.flags & ts.TypeFlags.Intersection) !== 0) + return ts + .constituentTypes(type) + .every( + (t) => + (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || + containerType(lowerer, t), + ); if ((type.flags & ts.TypeFlags.Object) === 0) return false; if (lowerer.checker.isArrayType(type) || lowerer.checker.isTupleType(type)) return true; const name = type.getSymbol()?.escapedName; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6901-node-island-array-intersection/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6901-node-island-array-intersection/main.ts.json new file mode 100644 index 000000000..3ef274510 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6901-node-island-array-intersection/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6901-node-island-array-intersection/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6901-node-island-array-intersection/main.ts b/tests/corpus/6901-node-island-array-intersection/main.ts new file mode 100644 index 000000000..55539393e --- /dev/null +++ b/tests/corpus/6901-node-island-array-intersection/main.ts @@ -0,0 +1,24 @@ +// @requires-libnode +// Reading an element of an island array whose declared type is an +// INTERSECTION of two array types — `Choice[] & GenChoice[]`, the island's +// choices extended by the caller's own choice type — has no native +// representation of its own, so the array stays a handle and the read runs +// in the island; the fields the declaration names cross back out. +import { extendedImageResponse, imageResponse, unsafe } from "choicekit"; +import type { Choice } from "choicekit"; + +const extended = extendedImageResponse(); +const first = extended.choices[0]; +console.log(first.index, first.text); +console.log(first.images === undefined ? "no-images" : first.images.join("+")); + +const second = extended.choices[1]; +console.log(second.index, second.text, second.images === undefined ? "no-images" : second.images.join("+")); + +/** The caller's own choice type over the island's array. */ +interface NativeChoice extends Choice { + readonly images?: string[]; +} + +const third = unsafe(imageResponse().choices)[1]; +console.log(third.text, third.images === undefined ? "no-images" : third.images.join("+")); diff --git a/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.d.ts b/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.d.ts new file mode 100644 index 000000000..d530f50cf --- /dev/null +++ b/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.d.ts @@ -0,0 +1,18 @@ +export interface Choice { + readonly index: number; + readonly text: string; +} +export interface GenChoice extends Choice { + readonly images?: string[]; +} +export interface ImageResponse { + readonly choices: Choice[]; +} +export interface ExtendedImageResponse { + /** The island's choices, extended by the island's own choice type. */ + readonly choices: Choice[] & GenChoice[]; +} +export declare function imageResponse(): ImageResponse; +export declare function extendedImageResponse(): ExtendedImageResponse; +/** Declares the type of the value it is given, like `Type.Unsafe`. */ +export declare function unsafe(value: T): T; diff --git a/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.js b/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.js new file mode 100644 index 000000000..f4168c42c --- /dev/null +++ b/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/index.js @@ -0,0 +1,10 @@ +"use strict"; + +const choices = () => [ + { index: 0, text: "first", images: ["a.png", "b.png"] }, + { index: 1, text: "second" }, +]; + +exports.imageResponse = () => ({ choices: choices() }); +exports.extendedImageResponse = () => ({ choices: choices() }); +exports.unsafe = (value) => value; diff --git a/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/package.json b/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/package.json new file mode 100644 index 000000000..87cb5448c --- /dev/null +++ b/tests/corpus/6901-node-island-array-intersection/node_modules/choicekit/package.json @@ -0,0 +1 @@ +{ "name": "choicekit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6901-node-island-array-intersection/package.json b/tests/corpus/6901-node-island-array-intersection/package.json new file mode 100644 index 000000000..4468bc168 --- /dev/null +++ b/tests/corpus/6901-node-island-array-intersection/package.json @@ -0,0 +1 @@ +{ "name": "island-array-intersection", "private": true, "type": "module", "scriptc": { "packages": { "choicekit": "node" } } } diff --git a/tests/harness/corpus-costs/6901-node-island-array-intersection/main.ts.json b/tests/harness/corpus-costs/6901-node-island-array-intersection/main.ts.json new file mode 100644 index 000000000..f2a54cf68 --- /dev/null +++ b/tests/harness/corpus-costs/6901-node-island-array-intersection/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 42000 } From 12a3f8a22533e8d3a815fc3fc56d2a76046d2444 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:50:28 -0700 Subject: [PATCH 18/44] Build spreads of union sources one arm at a time `{ ...u, k: v }` where `u` is a union of records (`{ ...msg, content }` in a message-mapping switch, `{ ...command, id } as Cmd`, `{ ...base, entries } as A | B`) is typed by TypeScript as one spread per arm of `u`, but the frontend mapped the literal to the union and refused with SC2003 ("a spread merge over union-typed sources"). The only union-source spreads that lowered were shared-field overrides and the single-record-arm present test. lowerDistributedUnionSpread builds the literal the way the checker types it. The source evaluates once into a hidden local; a tag dispatch builds the running arm's literal with that arm as the spread source, at the destination arm the discriminant's last writer picks, else the source arm itself, else the one arm whose keys are exactly the keys the literal writes. Every key a contributor may write must be a field of the destination, so a genuine merge (`{ ...http, ...patch }` where the patch can carry a stdio-only key) keeps the refusal instead of silently dropping keys. A stored source the checker narrowed converts through the checked re-tag first, so an excluded arm throws the catchable TypeError. Cost: the function runs only where lowerObjectLiteral would have refused; the new `exact` parameter defaults to false and the new `spreadArmOverrides` map is empty outside the dispatch, so every existing literal lowers to the same IR. `fix/records-literals` changes the same field-desugar capture (it adds a `checkedSpreadSource` block around it). This commit deliberately keeps the per-arm source as the expression `(lowerer.spreadArmOverrides.get(srcNode) ?? lowerer.lowerExpr(srcNode))` inside that capture, so the merge keeps records-literals' `let srcLowered` plus its checkedSource block and applies the override inside the same expression, as the design specifies. Pi sites: execution.ts:431, agent-session.ts:3795, runner.ts:1048, model-resolver.ts:216, sdk.ts:324, session-manager.ts:538, rpc-client.ts:577. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 229 +++++++++++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 4 + .../corpus/union-clone-override-width.ts.json | 6 + .../corpus/union-spread-distribution.ts.json | 6 + .../union-clone-override-width.ts.json | 6 - .../union-clone-override-width.ts | 3 +- tests/corpus/union-spread-distribution.ts | 134 ++++++++++ .../union-clone-override-width.ts.txt | 8 - .../union-clone-override-width.ts.json | 1 + .../union-spread-distribution.ts.json | 1 + 10 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json rename tests/{diagnostics => corpus}/union-clone-override-width.ts (79%) create mode 100644 tests/corpus/union-spread-distribution.ts delete mode 100644 tests/harness/__snapshots__/union-clone-override-width.ts.txt create mode 100644 tests/harness/corpus-costs/union-clone-override-width.ts.json create mode 100644 tests/harness/corpus-costs/union-spread-distribution.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7947f2830..b2e457681 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -43,6 +43,7 @@ import { symbolFieldDisplayName, } from "../symbol-fields.js"; import { planUnionRetag } from "../union-retag.js"; +import { literalValues } from "../../literal-values.js"; import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js"; import { builtinGlobalValue } from "./property-access.js"; import { holdsEvolvingArray } from "../lower-stmts.js"; @@ -850,6 +851,10 @@ export function lowerObjectLiteral( lowerer: Lowerer, expr: ts.ObjectLiteralExpression, expected?: IrType & { kind: "record" }, + /** Build exactly at `expected` (one arm of a distributed union spread, + * lowerDistributedUnionSpread): the checker types the literal as the + * whole union, so its contextual type cannot choose the layout. */ + exact = false, ): IrExpr { // A spread of an untyped (`any`) source into a typed slot: TypeScript // types such a literal `any`, and the copy holds whatever own properties @@ -968,7 +973,7 @@ export function lowerObjectLiteral( ) { return lowerDynObjectLiteral(lowerer, expr); } - const unionClone = lowerUnionRecordClone(lowerer, expr); + const unionClone = exact ? null : lowerUnionRecordClone(lowerer, expr); if (unionClone !== null) return unionClone; // The RUNTIME-KEYED literal (JS): a computed key that doesn't fold to a // compile-time string means the literal's shape is not a compile-time @@ -1106,7 +1111,7 @@ export function lowerObjectLiteral( ) { tsType = lowerer.checker.getAwaitedType(tsType) ?? tsType; } - let mapped = lowerer.mapTypeOf(tsType); + let mapped = exact && expected ? expected : lowerer.mapTypeOf(tsType); // A checked-dynamic source has runtime keys even when later explicit // fields make the literal's inferred type look like a fixed record. // Keep a checked-dynamic destination in that representation so copying @@ -1314,6 +1319,13 @@ export function lowerObjectLiteral( lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE); } if (!mapped || mapped.kind !== "record") { + // Distribute against the contextual union when there is one (its arms + // carry the discriminants), else against the literal's own union. + const ctx = lowerer.mapTypeOf(tsType); + const distributed = exact + ? null + : lowerDistributedUnionSpread(lowerer, expr, ctx?.kind === "union" ? ctx : mapped); + if (distributed) return distributed; // A spread merge over UNION-typed sources (`{ ...base, ...override }` // with `base`, `override` of arm-union types) has one field set per // combination of source arms, so its own type is a union of records. @@ -1915,7 +1927,7 @@ export function lowerObjectLiteral( ? lowerConditionalRecordSpread(lowerer, condRecord, locOf(prop)) : prop === expr.properties.nodes[0] && leadingSpreadLowered !== null ? leadingSpreadLowered - : lowerer.lowerExpr(srcNode), + : (lowerer.spreadArmOverrides.get(srcNode) ?? lowerer.lowerExpr(srcNode)), ); // Array callbacks can receive a record-or-undefined ABI even when // TypeScript describes a required record. Select the copy strategy @@ -2663,6 +2675,217 @@ function isEmptyArrayLiteral(node: ts.Node): boolean { return ts.isArrayLiteralExpression(inner) && inner.elements.nodes.length === 0; } +/** `{ ...u, k: v }` where the spread source `u` is a union of records (and + * unit arms): TypeScript types the literal as the union of one spread per + * source arm, so it is built that way. The source evaluates once into a + * hidden local; a tag dispatch builds the running arm's literal with that + * arm as the source, at the destination arm chosen for it: + * 1. the arms the discriminant's last writer selects (an explicit + * property, or the source arm when it carries the field); + * 2. among those, the source arm itself (`{ ...command, id }`); + * 3. else the one arm whose keys are exactly the keys the literal writes. + * Every key a contributor may write must be a field of the destination: + * the record path's width copy would drop the others, and Node keeps them. + * So a genuine merge (`{ ...http, ...patch }` where `patch` may carry a + * stdio-only key) stays refused. A stored source the checker narrowed + * converts through the checked re-tag first, so an excluded arm (a hidden + * absence arm included) throws the catchable TypeError. Null when the + * literal is not this shape, before anything is lowered except the + * source; the caller keeps its refusal. */ +function lowerDistributedUnionSpread( + lowerer: Lowerer, + expr: ts.ObjectLiteralExpression, + mapped: IrType | null, +): IrExpr | null { + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + if (mapped?.kind !== "union") return null; + const target = lowerer.unions.get(mapped.unionId); + if (!target) return null; + const props = expr.properties.nodes; + // Exactly one distributed source: a plain spread whose checker type maps + // to a union of records and unit arms. Several would be a product. + let spreadIndex = -1; + let srcNode: ts.Expression | null = null; + for (let i = 0; i < props.length; i++) { + const prop = props[i]!; + if (!ts.isSpreadAssignment(prop)) continue; + if ( + conditionalSpreadOf(prop.expression) !== null || + andSpreadOf(lowerer, prop.expression) !== null || + conditionalRecordSpreadOf(lowerer, prop.expression) !== null + ) + continue; + let node: ts.Expression = prop.expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (lowerer.mapTypeOf(lowerer.typeOf(node))?.kind !== "union") continue; + if (srcNode !== null) return null; + spreadIndex = i; + srcNode = node; + } + if (srcNode === null) return null; + const site = srcNode; + // The source evaluates before the dispatch, so everything written before + // it must be free of effects. + for (const prop of props.slice(0, spreadIndex)) { + if (ts.isShorthandPropertyAssignment(prop)) continue; + if (ts.isPropertyAssignment(prop) && pureKeyExpr(lowerer, prop.initializer)) continue; + return null; + } + const siteType = lowerer.mapTypeOf(lowerer.typeOf(site)); + if (siteType?.kind !== "union") return null; + const siteDef = lowerer.unions.get(siteType.unionId); + if (!siteDef || !siteDef.arms.every((arm) => arm.kind === "record" || isUnitType(arm))) + return null; + const discField = target.discriminant?.field; + // The discriminant values the literal's LAST writer of that field gives + // this source arm; null when unknown. + const discriminantOf = (tag: number, arm: IrType): ReturnType => { + if (discField === undefined) return null; + for (let i = props.length - 1; i >= 0; i--) { + const prop = props[i]!; + if (i === spreadIndex) { + if (arm.kind !== "record") continue; + if (!lowerer.shapes.get(arm.shapeId)?.fields.some((f) => f.name === discField)) continue; + const own = siteDef.discriminant; + return own?.field === discField + ? (own.cases.find((c) => c.tag === tag)?.values ?? null) + : null; + } + if (ts.isSpreadAssignment(prop)) return null; + if ( + (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && + propNameText(lowerer, prop.name) === discField + ) + return ts.isPropertyAssignment(prop) + ? literalValues(lowerer.typeOf(prop.initializer)) + : literalValues(lowerer.typeOf(prop.name)); + } + return null; + }; + // Every key some contributor may write for this source arm; null when a + // contributor has no fixed key set (an index signature, a non-record). + const writtenKeys = (arm: IrType): Set | null => { + const names = new Set(); + for (let i = 0; i < props.length; i++) { + const prop = props[i]!; + if (ts.isSpreadAssignment(prop)) { + let node: ts.Expression = prop.expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + const t = i === spreadIndex ? arm : lowerer.mapTypeOf(lowerer.typeOf(node)); + if (t !== null && isUnitType(t)) continue; + if (t?.kind !== "record") return null; + const shape = lowerer.shapes.get(t.shapeId); + if (!shape || shape.indexValue || shape.tuple) return null; + for (const f of shape.fields) names.add(f.name); + } else if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) { + names.add(propNameText(lowerer, prop.name)); + } else { + return null; + } + } + return names; + }; + const destinationOf = (tag: number, arm: IrType): (IrType & { kind: "record" }) | null => { + const keys = writtenKeys(arm); + if (keys === null) return null; + let candidates = target.arms + .map((a, t) => ({ a, t })) + .filter((c): c is { a: IrType & { kind: "record" }; t: number } => c.a.kind === "record") + .filter((c) => { + const fields = lowerer.shapes.get(c.a.shapeId)!.fields; + return [...keys].every((k) => fields.some((f) => f.name === k)); + }); + const values = discriminantOf(tag, arm); + if (values && values.length > 0 && target.discriminant) { + const cases = target.discriminant.cases; + candidates = candidates.filter((c) => + (cases.find((k) => k.tag === c.t)?.values ?? []).some((v) => values.includes(v)), + ); + } + if (candidates.length === 1) return candidates[0]!.a; + if (arm.kind === "record") { + const same = candidates.find((c) => c.a.shapeId === arm.shapeId); + if (same) return same.a; + } + const exactKeys = candidates.filter( + (c) => lowerer.shapes.get(c.a.shapeId)!.fields.length === keys.size, + ); + return exactKeys.length === 1 ? exactKeys[0]!.a : null; + }; + // Plan every arm before lowering anything. + const plan: { tag: number; arm: IrType; destination: IrType & { kind: "record" } }[] = []; + for (let tag = 0; tag < siteDef.arms.length; tag++) { + const arm = siteDef.arms[tag]!; + const destination = destinationOf(tag, arm); + if (destination === null) return null; + plan.push({ tag, arm, destination }); + } + const loc = locOf(expr); + let source = lowerer.lowerExpr(site); + if (source.type.kind === "union" && !typeEquals(source.type, siteType)) { + const helper = lowerer.narrowedRetagHelper( + site, + source.type.unionId, + siteType.unionId, + source.loc, + ); + if (helper) + source = { kind: "call", callee: helper, args: [source], type: siteType, loc: source.loc }; + } + if (!typeEquals(source.type, siteType)) return null; + const saved = lowerer.declareHiddenLocal("%spreadArms", siteType); + const ref = varRef(saved.id, siteType, loc); + let result: IrExpr | null = null; + for (let i = plan.length - 1; i >= 0; i--) { + const { tag, arm, destination } = plan[i]!; + const armValue: IrExpr = + arm.kind === "record" + ? { kind: "unionNarrow", value: ref, unionId: siteType.unionId, tag, type: arm, loc } + : { + kind: "recordLit", + fields: [], + type: { kind: "record", shapeId: lowerer.shapes.intern([]) }, + loc, + }; + lowerer.spreadArmOverrides.set(site, armValue); + let built: IrExpr; + try { + built = lowerObjectLiteral(lowerer, expr, destination, true); + } finally { + lowerer.spreadArmOverrides.delete(site); + } + const wrapped = lowerer.coerceToExpected(built, mapped); + if (!typeEquals(wrapped.type, mapped)) return null; + result = + result === null + ? wrapped + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + value: ref, + unionId: siteType.unionId, + tag, + negated: false, + type: BOOL, + loc, + }, + then: wrapped, + else_: result, + type: mapped, + loc, + }; + } + if (result === null) return null; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: saved.id, init: source, loc }], + result, + type: mapped, + loc, + }; +} + /** A union of plain records can preserve its existing variant while * replacing shared fields. Dispatch before constructing the copy: only * the selected arm evaluates overrides, and recordClone snapshots fields diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7700b7406..b4864293b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3548,6 +3548,9 @@ export class Lowerer { readonly neverValued = new Set(); readonly chainNarrowedType = new Map(); readonly chainHandled = new Set(); + /** One arm's narrowed value for a spread source while a distributed + * union spread lowers that arm (lowerDistributedUnionSpread). */ + readonly spreadArmOverrides = new Map(); /** for-of-over-matchAll bindings whose `.index` reads the companion-index * array: binding SYMBOL → the hidden number[] of match start indices plus * the hidden cursor holding THIS iteration's position (registered while @@ -4617,6 +4620,7 @@ export class Lowerer { "neverValued", "chainNarrowedType", "chainHandled", + "spreadArmOverrides", "splitIdentifierUses", "typeMemo", "fnStack", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json new file mode 100644 index 000000000..9f12d2cbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-clone-override-width.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json new file mode 100644 index 000000000..6a601d723 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-spread-distribution.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json deleted file mode 100644 index 0d30ae9e6..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/union-clone-override-width.ts" - ], - "diags": [] -} diff --git a/tests/diagnostics/union-clone-override-width.ts b/tests/corpus/union-clone-override-width.ts similarity index 79% rename from tests/diagnostics/union-clone-override-width.ts rename to tests/corpus/union-clone-override-width.ts index 9da9a008a..475632532 100644 --- a/tests/diagnostics/union-clone-override-width.ts +++ b/tests/corpus/union-clone-override-width.ts @@ -3,8 +3,7 @@ type WiderPayload = { tag: "number"; value: number; extra: boolean } | { tag: "t type Container = { kind: "left"; payload: Payload; left: number } | { kind: "right"; payload: Payload; right: number }; function copy(source: Container, payload: WiderPayload): boolean { - // Retagging the override into source.payload would discard extra and - // copy its record. The inferred spread result keeps the wider payload. + // An override wider than every source arm's field: the distributed spread builds each arm at the inferred result's arm, which keeps the wider payload. const result = { ...source, payload }; return result.payload.extra; } diff --git a/tests/corpus/union-spread-distribution.ts b/tests/corpus/union-spread-distribution.ts new file mode 100644 index 000000000..e1a6b54a9 --- /dev/null +++ b/tests/corpus/union-spread-distribution.ts @@ -0,0 +1,134 @@ +// Object spreads whose source is a union of records: TypeScript types +// `{ ...u, k: v }` as one spread per arm of `u`, and the literal is built +// that way. The running arm decides which destination arm holds the result: +// the discriminant's last writer, else the source arm itself, else the one +// arm with exactly the written keys. JSON.stringify pins key order too. + +// 1. One union source plus an override, inside an array callback, after a +// discriminant test narrowed the stored union. +type Text = { type: "text"; text: string }; +type Image = { type: "image"; data: string }; +type UserMsg = { role: "user"; content: string | (Text | Image)[]; ts: number }; +type AsstMsg = { role: "assistant"; content: Text[]; model: string }; +type ToolMsg = { role: "tool"; content: (Text | Image)[]; toolId: string; isError?: boolean }; +type Msg = UserMsg | AsstMsg | ToolMsg; + +function stripImages(msgs: Msg[]): Msg[] { + return msgs.map((msg) => { + if (msg.role === "user" || msg.role === "tool") { + const content = msg.content; + if (Array.isArray(content)) { + const filtered = content.map((c) => + c.type === "image" ? { type: "text" as const, text: "[image]" } : c, + ); + return { ...msg, content: filtered }; + } + } + return msg; + }); +} + +// 2. A union source plus a field the arms declare optional, cast back. +type Cmd = + | { id?: string; type: "prompt"; message: string } + | { id?: string; type: "abort" } + | { id?: string; type: "set_model"; provider: string; modelId: string }; +let next = 0; +function withId(command: Cmd): Cmd { + const id = `req_${++next}`; + return { ...command, id } as Cmd; +} + +// 3. An optional group `{} | { during }` spread into a discriminated result. +type Result = + | { ok: true; value: number; during?: { seq: number } } + | { ok: false; error: string; during?: { seq: number } }; +function finish(error: string | undefined, seq: number | undefined): Result { + const ended = seq === undefined ? {} : { during: { seq } }; + return error ? { ok: false, error, ...ended } : { ok: true, value: 1, ...ended }; +} + +// 4. A discriminant test leaves several arms; overrides set and clear keys. +type Ref = + | { status: "resolved"; name: string; level?: string; warning?: string } + | { status: "ambiguous"; names: string[]; level?: string; warning?: string } + | { status: "not-found" }; +function relevel(inner: Ref): Ref { + if (inner.status === "not-found") return inner; + return { ...inner, level: undefined, warning: "w" }; +} + +// 5. The source supplies the second half of an event whose discriminant +// value is shared by two arms; the written keys pick the arm. +type Ev = + | { type: "start" } + | { type: "retry"; source: "branch" } + | { type: "retry"; source: "compact"; reason: "manual" | "auto" } + | { type: "end"; count: number }; +const events: string[] = []; +function emit(event: Ev): void { + events.push(JSON.stringify(event)); +} +function retrying(source: { source: "branch" } | { source: "compact"; reason: "manual" | "auto" }) { + emit({ type: "retry", ...source }); +} + +// 6. A cast-built event from a union base. +type BaseA = { type: "settle"; id: string }; +type BaseB = { type: "turn"; id: string; turn: number }; +type SettleEv = BaseA & { entries: string[]; more: boolean }; +type TurnEv = BaseB & { entries: string[]; more: boolean }; +function boundary(base: BaseA | BaseB, entries: string[]): SettleEv | TurnEv { + return { ...base, entries, more: entries.length > 1 } as SettleEv | TurnEv; +} + +// 7. A message update whose override's type differs per arm. +type Part = { type: "text"; text: string }; +type UMsg = { role: "user"; content: string | Part[] }; +type AMsg = { role: "assistant"; content: Part[]; model: string }; +type Note = { role: "note"; body: string }; +type AnyMsg = UMsg | AMsg | Note; +function replace(messages: AnyMsg[], text: string): AnyMsg[] { + return messages.map((message) => { + if (message.role !== "user" && message.role !== "assistant") return message; + const content = message.role === "assistant" ? [{ type: "text" as const, text }] : text; + return { ...message, content } as AnyMsg; + }); +} + +const msgs: Msg[] = [ + { role: "user", content: [{ type: "image", data: "x" }, { type: "text", text: "hi" }], ts: 1 }, + { role: "assistant", content: [{ type: "text", text: "ok" }], model: "m" }, + { role: "tool", content: [{ type: "image", data: "y" }], toolId: "t1", isError: false }, + { role: "user", content: "plain", ts: 2 }, +]; +for (const m of stripImages(msgs)) console.log(JSON.stringify(m)); +console.log(JSON.stringify(withId({ type: "prompt", message: "go" }))); +console.log(JSON.stringify(withId({ type: "abort", id: "old" }))); +console.log(JSON.stringify(withId({ type: "set_model", provider: "p", modelId: "q" }))); +console.log(JSON.stringify(finish(undefined, undefined))); +console.log(JSON.stringify(finish("bad", 3))); +console.log(JSON.stringify(finish(undefined, 4))); +const resolved = relevel({ status: "resolved", name: "a", level: "hi" }); +console.log(JSON.stringify(resolved), "level" in resolved, "warning" in resolved); +console.log(JSON.stringify(relevel({ status: "ambiguous", names: ["a", "b"] }))); +console.log(JSON.stringify(relevel({ status: "not-found" }))); +retrying({ source: "branch" }); +retrying({ source: "compact", reason: "auto" }); +console.log(events.join(" ")); +console.log(JSON.stringify(boundary({ type: "settle", id: "s" }, ["a"]))); +console.log(JSON.stringify(boundary({ type: "turn", id: "t", turn: 3 }, ["a", "b"]))); +for (const m of replace( + [ + { role: "user", content: "old" }, + { role: "assistant", content: [], model: "m" }, + { role: "note", body: "n" }, + ], + "new", +)) + console.log(JSON.stringify(m)); +// The spread copies a snapshot: a later write to the source is not seen. +const base: Cmd = { type: "prompt", message: "before" }; +const copied = withId(base); +if (base.type === "prompt") base.message = "after"; +console.log(JSON.stringify(copied), JSON.stringify(base)); diff --git a/tests/harness/__snapshots__/union-clone-override-width.ts.txt b/tests/harness/__snapshots__/union-clone-override-width.ts.txt deleted file mode 100644 index da3819baf..000000000 --- a/tests/harness/__snapshots__/union-clone-override-width.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -union-clone-override-width.ts:8:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' can represent it (candidate arms: '{ kind: "left"; left: number; payload: WiderPayload; }', '{ kind: "right"; right: number; payload: WiderPayload; }') - - 7 | // copy its record. The inferred spread result keeps the wider payload. - 8 | const result = { ...source, payload }; - | ^~~~~~~~~~~~~~~~~~~~~~ - 9 | return result.payload.extra; - - hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/corpus-costs/union-clone-override-width.ts.json b/tests/harness/corpus-costs/union-clone-override-width.ts.json new file mode 100644 index 000000000..67573559b --- /dev/null +++ b/tests/harness/corpus-costs/union-clone-override-width.ts.json @@ -0,0 +1 @@ +{ "plain": 4537, "san": 6468 } diff --git a/tests/harness/corpus-costs/union-spread-distribution.ts.json b/tests/harness/corpus-costs/union-spread-distribution.ts.json new file mode 100644 index 000000000..d5d3b948a --- /dev/null +++ b/tests/harness/corpus-costs/union-spread-distribution.ts.json @@ -0,0 +1 @@ +{ "plain": 6958, "san": 12595 } From 7c788f706d86962eea0ad42907726c164a7daf13 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:50:35 -0700 Subject: [PATCH 19/44] Lower EventEmitter calls with any event name An event bus that forwards caller-chosen channels (`emitter.emit(channel, data)`, `emitter.on(channel, handler)`, `emitter.off(channel, safe)`) refused every call with SC2020: the static design types each literal event with one program-wide tuple, and `'error'` and the meta events have runtime-fixed ABIs, so a computed name needed a constant prefix or suffix. An unrestricted computed name on a non-stream emitter now takes the checked-dynamic receiver's existing bridge: emit lowers through lowerDynamicEmitterEmit when the program fixes a typed tuple for that name, else scr_emitter_emit_any (which keeps Node's `'error'` contract and the meta events); on/once/prepend register through lowerDynamicEmitterOn or scr_emitter_on_any, which adds runtime typed adapters for those events; off removes by the original closure. Node's semantics hold for every name, including the reserved ones. The one checked boundary is a typed listener registered under a literal name receiving a payload its parameter type rejects: that conversion throws the catchable TypeError, the bridge's existing "types don't lie" contract. Streams keep the refusal, since their runtime events carry fixed per-base ABIs. Cost: zero. Literal-name sites and the program-wide event table are unchanged (an unrestricted site adds no computed pattern), and the bridge helpers are emitted only at the new sites. No runtime change. Pi sites: event-bus.ts:16 and :26. Site :27 (`return () => emitter.off(...)` in a `() => void` slot) clears its emitter call but still depends on the separate value-returning-arrow-through-a-void-slot fix. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/lower-event-emitter.ts | 43 +++++++++-- .../corpus/emitter-any-event-names.ts.json | 6 ++ tests/corpus/emitter-any-event-names.ts | 71 +++++++++++++++++++ tests/diagnostics/ee-computed-unrestricted.ts | 6 +- .../ee-computed-unrestricted.ts.txt | 6 +- .../emitter-any-event-names.ts.json | 1 + 6 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-any-event-names.ts.json create mode 100644 tests/corpus/emitter-any-event-names.ts create mode 100644 tests/harness/corpus-costs/emitter-any-event-names.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 92cc7f23d..112415e70 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -604,6 +604,7 @@ function lowerFlexListenerCall( once: boolean, prepend: boolean, loc: SrcLoc, + anyName = false, ): IrExpr { if (receiver.type.kind !== "object") { lowerer.noLowering( @@ -633,10 +634,19 @@ function lowerFlexListenerCall( lowerer.noLowering(`${member} with a non-function computed-name listener`, cbNode); } const recvT = receiver.type; - const key = `emitter.${registering ? "onFlex" : "offDyn"}:${typeKey(recvT)}`; + // An UNRESTRICTED name may be any event, including 'error', the meta + // events, and the literal events typed code fixes a tuple for: register + // through the checked-dynamic receiver's bridge (typed adapters for those + // events, the thunk alone otherwise), which keeps Node's contract for each. + if (anyName && registering) { + const bridged = lowerDynamicEmitterOn(lowerer, receiver, name, boxed, once, prepend, loc); + if (bridged) return bridged; + } + const onFn = anyName ? "onAny" : "onFlex"; + const key = `emitter.${registering ? onFn : "offDyn"}:${typeKey(recvT)}`; let helper = lowerer.arrHofHelpers.get(key); if (!helper) { - helper = keyedHelperName(lowerer, `%emitter.${registering ? "onFlex" : "offFlex"}`, key); + helper = keyedHelperName(lowerer, `%emitter.${registering ? onFn : "offFlex"}`, key); lowerer.arrHofHelpers.set(key, helper); const params: IrParam[] = [ { localId: "r.0", name: "r", type: recvT }, @@ -671,7 +681,7 @@ function lowerFlexListenerCall( kind: "return", value: { kind: "libCall", - fn: registering ? "emitter.onFlex" : "emitter.offDyn", + fn: registering ? (anyName ? "emitter.onAny" : "emitter.onFlex") : "emitter.offDyn", args: [ varRef("r.0", recvT, loc), varRef("n.0", STRING, loc), @@ -1309,7 +1319,7 @@ export function lowerEmitterMethodCall( } return receiver; }; - const lowerFlexEmit = (name: IrExpr, kind = "computed-name"): IrExpr => { + const lowerFlexEmit = (name: IrExpr, kind = "computed-name", anyName = false): IrExpr => { const below: ClassInfo[] = []; collectEmitOverridesBelow(info, below); if (emitOverrideAtOrAbove(info) || below.length > 0) { @@ -1328,6 +1338,20 @@ export function lowerEmitterMethodCall( ); } const payload = args.slice(1).map((arg) => lowerer.lowerExprExpecting(arg, DYN)); + if (anyName) { + // Any event name: the checked-dynamic receiver's emit, which keeps + // 'error' and the meta events' contracts and converts the arguments + // for listeners registered with a typed tuple. + return ( + lowerDynamicEmitterEmit(lowerer, receiver, name, payload, loc) ?? { + kind: "libCall", + fn: "emitter.emitAny", + args: [receiver, name, ...payload], + type: BOOL, + loc, + } + ); + } return { kind: "libCall", fn: "emitter.emitFlex", @@ -1430,7 +1454,11 @@ export function lowerEmitterMethodCall( } const registering = REGISTER_MEMBERS.has(member); if (literalEventName(lowerer, args[0]!) === null) { - computedEventPattern(lowerer, info, member, args[0]!); + // An unrestricted name on a plain emitter takes the any-name path; + // streams keep the fence (their runtime events carry fixed ABIs). + const anyName = + computedEventPatternOf(lowerer, args[0]!) === null && streamSidesOf(lowerer, info) === null; + if (!anyName) computedEventPattern(lowerer, info, member, args[0]!); const receiver = lowerReceiver(); const name = lowerer.lowerExprExpecting(args[0]!, STRING); return lowerFlexListenerCall( @@ -1443,6 +1471,7 @@ export function lowerEmitterMethodCall( member === "once" || member === "prependOnceListener", member === "prependListener" || member === "prependOnceListener", loc, + anyName, ); } const name = eventNameOf(lowerer, member, args[0]!); @@ -1636,6 +1665,10 @@ export function lowerEmitterMethodCall( loc, }; } + if (pattern === null && streamSidesOf(lowerer, info) === null) { + const name = lowerer.lowerExprExpecting(args[0]!, STRING); + return lowerFlexEmit(name, "computed-name", true); + } computedEventPattern(lowerer, info, member, args[0]!); const name = lowerer.lowerExprExpecting(args[0]!, STRING); return lowerFlexEmit(name); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-any-event-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-any-event-names.ts.json new file mode 100644 index 000000000..f14e4a7f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/emitter-any-event-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/emitter-any-event-names.ts" + ], + "diags": [] +} diff --git a/tests/corpus/emitter-any-event-names.ts b/tests/corpus/emitter-any-event-names.ts new file mode 100644 index 000000000..6f07f526c --- /dev/null +++ b/tests/corpus/emitter-any-event-names.ts @@ -0,0 +1,71 @@ +// EventEmitter calls whose event name is an arbitrary string (an event bus +// that forwards caller-chosen channels). Every name keeps Node's contract: +// 'error' with no listener throws, listeners registered under a literal +// name with typed parameters receive converted arguments, and off removes +// by the original listener. +import { EventEmitter } from "node:events"; + +interface Bus { + emit(channel: string, data: unknown): void; + on(channel: string, handler: (data: unknown) => void): () => void; + clear(): void; +} + +function createBus(emitter: EventEmitter): Bus { + return { + emit: (channel, data) => { + emitter.emit(channel, data); + }, + on: (channel, handler) => { + const safe = async (data: unknown) => { + try { + await handler(data); + } catch (err) { + console.log(`handler error (${channel}):`, (err as Error).message); + } + }; + emitter.on(channel, safe); + return () => { + emitter.off(channel, safe); + }; + }, + clear: () => { + emitter.removeAllListeners(); + }, + }; +} + +const emitter = new EventEmitter(); +const bus = createBus(emitter); +const off = bus.on("greet", (d) => console.log("greet", JSON.stringify(d))); +bus.on("greet", () => { + throw new Error("boom"); +}); +bus.emit("greet", { who: "a" }); +bus.emit("nobody", 1); +console.log("count", emitter.listenerCount("greet")); +off(); +console.log("count after off", emitter.listenerCount("greet")); +bus.emit("greet", "after off"); + +// A listener registered under a literal name with a typed parameter. +emitter.on("count", (n: number) => console.log("typed count", n + 1)); +bus.emit("count", 41); +// A literal-name typed emit reaches a listener registered by computed name. +bus.on("count", (d) => console.log("bus count", JSON.stringify(d))); +emitter.emit("count", 7); + +// 'error' through a computed name: no listener throws the payload. +const errName = ["er", "ror"].join(""); +try { + bus.emit(errName, new Error("unhandled")); +} catch (e) { + console.log("threw", (e as Error).message); +} +bus.on(errName, (e) => console.log("error listener", (e as Error).message)); +bus.emit(errName, new Error("handled")); + +bus.clear(); +bus.emit("greet", "cleared"); +console.log("names", emitter.eventNames().length); +setTimeout(() => console.log("done"), 0); diff --git a/tests/diagnostics/ee-computed-unrestricted.ts b/tests/diagnostics/ee-computed-unrestricted.ts index 7def7b707..a87387a96 100644 --- a/tests/diagnostics/ee-computed-unrestricted.ts +++ b/tests/diagnostics/ee-computed-unrestricted.ts @@ -1,5 +1,5 @@ -import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; -const emitter = new EventEmitter(); +const stream = new PassThrough(); const name = process.argv[2] ?? "topic"; -emitter.on(name, () => {}); +stream.on(name, () => {}); diff --git a/tests/harness/__snapshots__/ee-computed-unrestricted.ts.txt b/tests/harness/__snapshots__/ee-computed-unrestricted.ts.txt index f436d603a..df1daabb8 100644 --- a/tests/harness/__snapshots__/ee-computed-unrestricted.ts.txt +++ b/tests/harness/__snapshots__/ee-computed-unrestricted.ts.txt @@ -1,8 +1,8 @@ -ee-computed-unrestricted.ts:5:12 - error SC2020: 'on with an unrestricted computed event name' is part of the standard library types but has no scriptc lowering yet +ee-computed-unrestricted.ts:5:11 - error SC2020: 'on with an unrestricted computed event name' is part of the standard library types but has no scriptc lowering yet 4 | const name = process.argv[2] ?? "topic"; - 5 | emitter.on(name, () => {}); - | ^~~~ + 5 | stream.on(name, () => {}); + | ^~~~ 6 | hint: computed event names need a nonempty constant prefix or suffix so they cannot collide with internal events \ No newline at end of file diff --git a/tests/harness/corpus-costs/emitter-any-event-names.ts.json b/tests/harness/corpus-costs/emitter-any-event-names.ts.json new file mode 100644 index 000000000..b22cfee56 --- /dev/null +++ b/tests/harness/corpus-costs/emitter-any-event-names.ts.json @@ -0,0 +1 @@ +{ "plain": 6928, "san": 116672 } From 8d6f723ed586b8a6fd6ec4089b15b4b6f6ee767c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:50:35 -0700 Subject: [PATCH 20/44] Read members of always-defined globals through optional chains The browser-guard idiom `const proc = (globalThis as { process?: { env?: E } }).process; return proc?.env;` failed: `proc` is stdlib-global alias plumbing with no storage, and `?.` lowered the receiver as a value to test it for nullishness (SC1090 "a binding form with no lowering"; SC2020 plus SC2004 on the re-measured branch). `process.env` as a value already lowered as a snapshot when the destination maps to an index record, but the cast types it `env?: Record<...>`, so the destination was `Record<...> | undefined` and the existing rule did not look through the unit arm. Optional steps over a global Node always defines now lower as the plain member read, the existing navigator rule: the receiver is a snapshot alias (`stdlibGlobalAliases`) or a property-access spelling of a global in the snapshot list. The list moves into the exported SNAPSHOT_GLOBALS, and `process.env` whose destination is a union of one index-record arm and unit arms snapshots into that record arm; the caller's coercion wraps it back into the optional type. Reads are exact; `process.env` as a value stays a snapshot (the existing rule), and a write through such a value still does not reach the real environment. Cost: zero for programs that compiled before, with one known exception: a program containing `globalThis.?.member` (or a cast spelling of it) used to compile into a runtime fence and now lowers as the plain read. The zero-cost gate lists those programs if any exist. Pi sites: ai/auth/context.ts:15 and its SC2004 use. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/process.ts | 12 +++++- .../lowering/expressions/optional-chains.ts | 21 +++++++++- .../src/frontend/lowering/surfaces.ts | 39 ++++++++++--------- .../global-snapshot-optional-reads.ts.json | 6 +++ .../corpus/global-snapshot-optional-reads.ts | 27 +++++++++++++ .../global-snapshot-optional-reads.ts.json | 1 + 6 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/global-snapshot-optional-reads.ts.json create mode 100644 tests/corpus/global-snapshot-optional-reads.ts create mode 100644 tests/harness/corpus-costs/global-snapshot-optional-reads.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index f250bf0f3..b9947caa7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -28,6 +28,7 @@ import { arrayOf, canBoxFuncIntoDyn, funcOf, + isUnitType, } from "../../../ir/ir.js"; import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js"; import { builtinFunctionValue } from "../lower-exprs.js"; @@ -844,7 +845,16 @@ export function lowerProcessProperty( return { kind: "libCall", fn: "process.execPath", args: [], type: STRING, loc }; } if (member === "env") { - const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + let mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + // `(globalThis as { process?: { env?: E } }).process?.env`: the cast + // types env as optional, but Node always defines it. Snapshot into the + // record arm; the caller's coercion wraps it into the optional type. + if (mapped?.kind === "union") { + const arms = lowerer.unions.get(mapped.unionId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if (records.length === 1 && arms.every((arm) => arm.kind === "record" || isUnitType(arm))) + mapped = records[0]!; + } if (mapped?.kind === "record") { const helper = envSnapshotHelper(lowerer, mapped.shapeId, loc); if (helper !== null) { diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 717fd8c66..20ef899c7 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -1,4 +1,5 @@ import { isNavigatorGlobal } from "../builtins/navigator.js"; +import { SNAPSHOT_GLOBALS, stdlibGlobalNameOf } from "../surfaces.js"; import { completeFuncValueArgs } from "../call-arguments.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; @@ -172,6 +173,24 @@ function alwaysPresentStdlibMethod(lowerer: Lowerer, node: ts.Expression): boole ); } +/** `proc?.env` for `const proc = (globalThis as { process?: P }).process`, + * and `globalThis.process?.env`: the receiver is a global Node always + * defines (a snapshot alias is plumbing for the global itself), so the + * guarded step is the plain member read (the navigator rule). */ +function alwaysDefinedGlobal(lowerer: Lowerer, node: ts.Expression): boolean { + let inner = node; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + // The bare global's own spelling (`console?.log`) keeps its existing path. + if (ts.isIdentifier(inner)) { + const symbol = lowerer.resolveValueSymbol(inner); + if (symbol === null || !lowerer.stdlibGlobalAliases.has(symbol)) return false; + } else if (!ts.isPropertyAccessExpression(inner)) { + return false; + } + const name = stdlibGlobalNameOf(lowerer, inner); + return name !== null && SNAPSHOT_GLOBALS.has(name); +} + export function lowerOptionalChain( lowerer: Lowerer, expr: ts.CallExpression | ts.PropertyAccessExpression | ts.ElementAccessExpression, @@ -225,7 +244,7 @@ export function lowerOptionalChain( // reports its own diagnostics) instead of failing on the value read. // `navigator?.userAgent`: Node's navigator global is never nullish and // has no value form, so the guarded step is the plain member read. - if (isNavigatorGlobal(lowerer, recvNode)) { + if (isNavigatorGlobal(lowerer, recvNode) || alwaysDefinedGlobal(lowerer, recvNode)) { lowerer.chainHandled.add(dotNode); try { return lowerer.lowerExpr(expr); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 71a2ace6c..f74c4370e 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -3768,26 +3768,29 @@ export function stdlibGlobalAliasNameOf( // These globals have provenance-aware call or member lowering, so a // stable snapshot keeps the same dispatch as the original global. // Other callable globals still use their ordinary value paths. - return [ - "process", - "console", - "globalThis", - "performance", - "Array", - "Buffer", - "BigInt", - "Number", - "String", - "Boolean", - // Code generation from strings throws Node's EvalError at every call - // (code-generation.ts); zod 4 probes `const F = Function; new F("")`. - "Function", - "eval", - ].includes(name) - ? name - : null; + return SNAPSHOT_GLOBALS.has(name) ? name : null; } +/** The globals a `const x = ` snapshot aliases (stdlibGlobalAliasNameOf). + * Node defines every one of them, so an optional step over one of them + * (`proc?.env`, `globalThis.process?.env`) is the plain member read. */ +export const SNAPSHOT_GLOBALS: ReadonlySet = new Set([ + "process", + "console", + "globalThis", + "performance", + "Array", + "Buffer", + "BigInt", + "Number", + "String", + "Boolean", + // Code generation from strings throws Node's EvalError at every call + // (code-generation.ts); zod 4 probes `const F = Function; new F("")`. + "Function", + "eval", +]); + /** `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 { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/global-snapshot-optional-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/global-snapshot-optional-reads.ts.json new file mode 100644 index 000000000..ada63aa54 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/global-snapshot-optional-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/global-snapshot-optional-reads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/global-snapshot-optional-reads.ts b/tests/corpus/global-snapshot-optional-reads.ts new file mode 100644 index 000000000..aa2d06da4 --- /dev/null +++ b/tests/corpus/global-snapshot-optional-reads.ts @@ -0,0 +1,27 @@ +// Optional reads through a snapshot of a global Node always defines: the +// browser-guard idiom `(globalThis as { process?: ... }).process?.env`, +// directly and through a const binding. +type Env = Record; + +function viaBinding(): Env | undefined { + const proc = (globalThis as { process?: { env?: Env } }).process; + return proc?.env; +} +function direct(): Env | undefined { + return (globalThis as { process?: { env?: Env } }).process?.env; +} +function viaGlobal(): Env | undefined { + return globalThis.process?.env; +} +function platform(): string { + const proc = (globalThis as { process?: { platform?: string } }).process; + return proc?.platform ?? "browser"; +} + +process.env.SNAPSHOT_PROBE = "yes"; +console.log(viaBinding()?.SNAPSHOT_PROBE, direct()?.SNAPSHOT_PROBE, viaGlobal()?.SNAPSHOT_PROBE); +console.log(viaBinding()?.["SNAPSHOT_MISSING"] ?? "(unset)"); +console.log(typeof viaBinding(), platform() === process.platform); +// Each call reads the current environment. +process.env.SNAPSHOT_PROBE = "changed"; +console.log(viaBinding()?.SNAPSHOT_PROBE); diff --git a/tests/harness/corpus-costs/global-snapshot-optional-reads.ts.json b/tests/harness/corpus-costs/global-snapshot-optional-reads.ts.json new file mode 100644 index 000000000..b757e1e31 --- /dev/null +++ b/tests/harness/corpus-costs/global-snapshot-optional-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 4737, "san": 7029 } From b767c38e9e1210fc70ad2ca7eab85e23ea7a9b61 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 07:30:40 -0700 Subject: [PATCH 21/44] Build union spread merges as the checked-dynamic object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ ...base, ...override }` over union-typed sources has one field set per combination of source arms, so the merged value can carry fields from several arms at once. When the per-arm distribution (slice 1a) cannot pick one arm, the merge was refused with SC2003 and the site did not compile (provider-composer's all-optional compat unions, mcp's aggregate config). Build the object Node builds instead: the checked-dynamic literal, whose keys are written in source order through the existing dynamic-object path (field chunks, spreads by copy-data-properties, computed keys by propertyKey), then converted into the declared union by the existing checked conversion. Reflection, JSON and inspection see the keys Node keeps; a value the union cannot hold throws the catchable TypeError at the conversion rather than being reinterpreted as one arm. The fallback stays narrow: union-typed spread source, auto tier, no capture buffer, and a union the checked extraction can validate — everything that compiles today lowers exactly as before, and `--tier=static-only` keeps the refusal that tests/diagnostics/union-spread-merge.ts pins. Corpus union-spread-merge.ts pins key order, last-writer-wins, nested identity, the accepting typed consumer and the snapshot semantics differentially; dyncheck's "union spread merge" covers the refusing single-arm reinterpretation scriptc-only, since Node never checks a cast. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 53 ++++++++++- .../tests/corpus/union-spread-merge.ts.json | 6 ++ tests/corpus/union-spread-merge.ts | 91 +++++++++++++++++++ tests/diagnostics/union-spread-merge.ts | 6 ++ .../__snapshots__/union-spread-merge.ts.txt | 8 +- .../corpus-costs/union-spread-merge.ts.json | 1 + tests/harness/dyncheck.test.ts | 34 +++++++ 7 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json create mode 100644 tests/corpus/union-spread-merge.ts create mode 100644 tests/harness/corpus-costs/union-spread-merge.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index b2e457681..18668ea98 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1322,9 +1322,8 @@ export function lowerObjectLiteral( // Distribute against the contextual union when there is one (its arms // carry the discriminants), else against the literal's own union. const ctx = lowerer.mapTypeOf(tsType); - const distributed = exact - ? null - : lowerDistributedUnionSpread(lowerer, expr, ctx?.kind === "union" ? ctx : mapped); + const unionCtx = ctx?.kind === "union" ? ctx : mapped; + const distributed = exact ? null : lowerDistributedUnionSpread(lowerer, expr, unionCtx); if (distributed) return distributed; // A spread merge over UNION-typed sources (`{ ...base, ...override }` // with `base`, `override` of arm-union types) has one field set per @@ -1332,8 +1331,13 @@ export function lowerObjectLiteral( // No single arm of the contextual union can hold that value without // dropping the fields the other arms contributed (Node keeps them // all), and a runtime tag would be a guess about which arm the value - // "is". Refuse, naming the candidate arms. + // "is". Build the object Node builds instead — the checked-dynamic + // copy — and let the union's checked conversion validate it. if (mapped?.kind === "union") { + if (!exact && unionCtx?.kind === "union") { + const merged = lowerUnionSpreadMergeDyn(lowerer, expr, unionCtx); + if (merged) return merged; + } const members = tsType.isUnionType() ? ts.constituentTypes(tsType) : [tsType]; const arms = [...new Set(members.map((m) => lowerer.checker.typeToString(m)))]; lowerer.pushDiag( @@ -2886,6 +2890,47 @@ function lowerDistributedUnionSpread( }; } +/** `{ ...a, ...b }` over union-typed sources that lowerDistributedUnionSpread + * declined: no single destination arm holds every key the contributors may + * write (`provider-composer`'s two all-optional compat unions, an aggregate + * config merged from a http|stdio union plus a patch naming both). Node + * builds ONE plain object: each source's own enumerable keys copy in source + * order (later writers win), then the explicit fields write in their own + * positions. Build exactly that object — the checked-dynamic literal — and + * validate it into the declared union at the slot. The value keeps its + * runtime keys (reflection, JSON, and inspection see what Node sees); a + * value the union cannot hold throws the catchable TypeError at the + * conversion instead of being reinterpreted as one arm. + * + * Null when the shape is not the SC2003 one (no union-typed spread source), + * when the build must not demote (inside a capture buffer, or under + * `--tier=static-only`, which keeps today's refusal), or when the union has + * no checked extraction. The caller then keeps its refusal. */ +function lowerUnionSpreadMergeDyn( + lowerer: Lowerer, + expr: ts.ObjectLiteralExpression, + target: IrType & { kind: "union" }, +): IrExpr | null { + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + const spreadsUnion = expr.properties.nodes.some((prop) => { + if (!ts.isSpreadAssignment(prop)) return false; + let node: ts.Expression = prop.expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + return lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "union"; + }); + if (!spreadsUnion) return null; + if (lowerer.tierMode !== "auto" || lowerer.diagSink !== null) return null; + if ( + !canDynCheckTo( + target, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + return null; + return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), target); +} + /** A union of plain records can preserve its existing variant while * replacing shared fields. Dispatch before constructing the copy: only * the selected arm evaluates overrides, and recordClone snapshots fields diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json new file mode 100644 index 000000000..aec1baf33 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-spread-merge.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-spread-merge.ts b/tests/corpus/union-spread-merge.ts new file mode 100644 index 000000000..c95ef04fc --- /dev/null +++ b/tests/corpus/union-spread-merge.ts @@ -0,0 +1,91 @@ +// A spread merge over union-typed sources whose destination union has no +// single arm for the result: `{ ...base, ...override }` can carry fields from +// several arms at once, and Node keeps them all. Where the per-arm +// distribution cannot pick one arm (see union-spread-distribution.ts), the +// merge builds the object Node builds — a checked-dynamic copy that writes +// keys in source order and validates the value at its typed slots. +// JSON pins key order, last-writer-wins and the kept keys; the identity check +// shows nested values are copied by reference; the typed consumer and the +// post-merge write exercise the checked conversion. + +interface OpenAiCompat { + supportsStore?: boolean; +} + +interface AnthropicCompat { + supportsLongCacheRetention?: boolean; +} + +type Compat = OpenAiCompat | AnthropicCompat; + +// 1. The provider-composer merge: every arm is all-optional, so no arm can +// represent the union of both key sets. The keys land in source order. +function mergeCompat(base: Compat | undefined, override: Compat | undefined): Compat | undefined { + if (!override) return base; + return { ...base, ...override } as NonNullable; +} + +const merged = mergeCompat({ supportsStore: true }, { supportsLongCacheRetention: true }); +if (merged !== undefined) console.log("merged", JSON.stringify(merged)); +const kept = mergeCompat({ supportsStore: true }, undefined); +if (kept !== undefined) console.log("no-override", JSON.stringify(kept)); +const filled = mergeCompat(undefined, { supportsLongCacheRetention: true }); +if (filled !== undefined) console.log("no-base", JSON.stringify(filled)); + +// 2. Overlapping keys: the later source wins the value, the earlier source +// keeps the key's position. +interface Left { + first?: string; + shared?: string; +} +interface Right { + shared?: string; + second?: string; +} +type Side = Left | Right; +function overlap(base: Side, override: Side): Side { + return { ...base, ...override } as Side; +} +console.log( + "overlap", + JSON.stringify(overlap({ first: "f", shared: "from-base" }, { shared: "from-override", second: "s" })), +); + +// 3. Nested values are copied by reference, not deep-copied. +interface Inner { + v: number; +} +interface XArm { + kind: "x"; + inner: Inner; + x?: number; +} +interface YArm { + kind: "y"; + inner: Inner; + y?: number; +} +type XY = XArm | YArm; +const inner: Inner = { v: 9 }; +const nested = { ...({ kind: "x", inner: { v: 1 }, x: 2 } as XY), ...({ kind: "y", inner, y: 3 } as XY) } as XY; +if (nested.kind === "y") console.log("nested", JSON.stringify(nested), nested.inner.v, nested.inner === inner); + +// 4. A typed consumer accepts the merged value; the checked conversion at the +// parameter is what validates it. +function describe(compat: Compat): string { + return JSON.stringify(compat); +} +if (merged !== undefined) console.log("describe", describe(merged)); +console.log("describe-single", describe({ supportsStore: false })); + +// 5. The merge snapshots its sources: a later write to a source is not seen. +const source: OpenAiCompat = { supportsStore: true }; +const snapshot = mergeCompat(source, { supportsLongCacheRetention: true }); +source.supportsStore = false; +if (snapshot !== undefined) console.log("snapshot", JSON.stringify(snapshot), JSON.stringify(source)); + +// 6. A merge with an explicit field after the spreads. +function withExtra(base: Compat, override: Compat): Compat { + return { ...base, ...override, supportsStore: true } as NonNullable; +} +console.log("extra", JSON.stringify(withExtra({ supportsStore: false }, { supportsLongCacheRetention: true }))); diff --git a/tests/diagnostics/union-spread-merge.ts b/tests/diagnostics/union-spread-merge.ts index dae422070..15d98c7b0 100644 --- a/tests/diagnostics/union-spread-merge.ts +++ b/tests/diagnostics/union-spread-merge.ts @@ -4,6 +4,12 @@ // can represent it (naming one would drop the other arms' fields, which Node // keeps), and no runtime tag would be honest. The refusal names the candidate // arms; the repair is one arm per merge or one open all-optional record type. +// +// This fixture pins the refusal under `--tier=static-only`. The default tier +// builds the object Node builds instead — a checked-dynamic copy whose keys +// are written in source order and validated at each typed slot (see +// tests/corpus/union-spread-merge.ts and the dyncheck fixture +// "union spread merge") — so it is static-only that keeps this diagnostic. interface OpenAiCompat { kind: "openai"; supportsStore?: boolean; diff --git a/tests/harness/__snapshots__/union-spread-merge.ts.txt b/tests/harness/__snapshots__/union-spread-merge.ts.txt index e3b39e2b6..a64fdecf5 100644 --- a/tests/harness/__snapshots__/union-spread-merge.ts.txt +++ b/tests/harness/__snapshots__/union-spread-merge.ts.txt @@ -1,8 +1,8 @@ -union-spread-merge.ts:21:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat') +union-spread-merge.ts:27:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat') - 20 | if (!override) return base; - 21 | const merged = { ...base, ...override } as NonNullable; + 26 | if (!override) return base; + 27 | const merged = { ...base, ...override } as NonNullable; | ^~~~~~~~~~~~~~~~~~~~~~~~ - 22 | return merged; + 28 | return merged; hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/corpus-costs/union-spread-merge.ts.json b/tests/harness/corpus-costs/union-spread-merge.ts.json new file mode 100644 index 000000000..2bbfce362 --- /dev/null +++ b/tests/harness/corpus-costs/union-spread-merge.ts.json @@ -0,0 +1 @@ +{ "plain": 5100, "san": 3300 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 97def0cc6..39739a128 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -1123,6 +1123,40 @@ console.log("recovered"); expect(r.stdout).toBe("TypeError true\nscalar: TypeError\nrecovered\n"); }); + test("a union spread merge's checked-dynamic object refuses a single-arm reinterpretation catchably", async () => { + // Where the per-arm distribution cannot pick one arm, a spread merge + // over union sources builds the object Node builds — the checked- + // dynamic copy — and the union's checked conversion validates it at + // every typed slot. Node keeps the merged keys through a narrower + // `as`; scriptc refuses to reinterpret them silently and throws the + // catchable TypeError instead. That makes this path scriptc-only: + // corpus union-spread-merge.ts pins the accepting paths differentially. + const r = await compileAndRun( + "union-spread-merge-dyn", + `interface OpenAiCompat { supportsStore?: boolean } +interface AnthropicCompat { supportsLongCacheRetention?: boolean } +type Compat = OpenAiCompat | AnthropicCompat; +function mergeCompat(base: Compat, override: Compat): Compat { + return { ...base, ...override } as Compat; +} +const merged = mergeCompat({ supportsStore: true }, { supportsLongCacheRetention: true }); +console.log(JSON.stringify(merged)); +try { + const single = merged as OpenAiCompat; + console.log("unreachable", JSON.stringify(single)); +} catch (e) { + console.log((e as Error).name, (e as Error).message.includes("not representable in the target union")); +} +console.log("recovered"); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + '{"supportsStore":true,"supportsLongCacheRetention":true}\nTypeError true\nrecovered\n', + ); + expect(r.stderr).toBe(""); + }); + test("a unit smuggled into a PLAIN non-nullable slot throws the stranded trap catchably", async () => { // The stranded-UNIT trap without a union in sight: `null!` / `null as // any as T` into a plain typed slot (string, array, class, function). From 306db2f3b1102a6af2516f0b4c4f0bf512f2f95c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:27:18 -0500 Subject: [PATCH 22/44] Address review comments on builtin-patch islands and the Test262 lane - Record CommonJS builtin reads through bracket notation and destructuring, so a native consumer of a patched member is islanded or reported with SC8020. A computed key or rest element reads the whole module and matches any patch of it. - Keep the named-data walk of an island module inside its directory: a symbolic link that leads out of it is not followed, and a link loop ends the walk. - Skip the extension check for import type and export type, which Node erases without resolving the specifier. - Scan TypeScript island modules for the program modules they import, so a stateful module used by both sides is shared instead of copied. - Export the Test262 assertion form of compareArray as assertCompareArray, so the harness no longer declares compareArray twice, and give island tests the bare compareArray as native ones have. --- packages/compiler/src/executable/prepare.ts | 18 +++- .../frontend/lowering/lower-shared-modules.ts | 13 ++- .../compiler/src/node-islands/detect.test.ts | 20 ++++ packages/compiler/src/node-islands/detect.ts | 92 +++++++++++++++++-- .../compiler/src/node-islands/modules.test.ts | 19 ++++ packages/compiler/src/node-islands/modules.ts | 18 +++- .../src/node-islands/type-strip.test.ts | 43 +++++++++ .../compiler/src/node-islands/type-strip.ts | 7 +- tests/harness/node-island.test.ts | 53 +++++++++++ tests/harness/test262-runner.test.ts | 37 ++++++++ tests/test262/harness.ts | 6 +- tests/test262/support.mjs | 10 +- 12 files changed, 315 insertions(+), 21 deletions(-) create mode 100644 packages/compiler/src/node-islands/type-strip.test.ts diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index fd71975c1..a00ffacea 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -646,14 +646,24 @@ function decideNodeIslands( * "fs.readFileSync"). */ function builtinModuleOf(member: string | undefined): string { if (member === undefined) return ""; - const dot = member.indexOf("."); - return dot < 0 ? member : member.slice(0, dot); + const end = /[.[]/.exec(member); + return end === null ? member : member.slice(0, end.index); } /** The island module that patches `member`, matching an exact member or a - * whole-module patch ("fs.*"). */ + * whole-module patch ("fs.*"). A read the scan cannot name ("fs.*", a + * computed key or a rest element) matches every patch of its module, and so + * does a patch through a computed key ("fs[…]"). */ function patchFileOf(patched: ReadonlyMap, member: string): string | undefined { - return patched.get(member) ?? patched.get(`${builtinModuleOf(member)}.*`); + const module = builtinModuleOf(member); + const exact = patched.get(member) ?? patched.get(`${module}.*`); + if (exact !== undefined) return exact; + const unnamedRead = member === `${module}.*`; + for (const [patch, file] of patched) { + if (builtinModuleOf(patch) !== module) continue; + if (unnamedRead || patch.includes("[")) return file; + } + return undefined; } /** The island set grown by the npm packages whose own modules refused to diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index ecae17894..94843bcb7 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -417,15 +417,22 @@ export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] { packageJsFiles(island.dir, queue); // A program module the build runs in a Node island is loaded by Node, so // the program modules IT imports are shared with it like an island - // package's (DESIGN.md note 1 §3.2). - for (const module of lowerer.nodeIslands?.modules.values() ?? []) queue.push(real(module.file)); + // package's (DESIGN.md note 1 §3.2). It may be TypeScript (Node strips + // its types), so it is scanned whatever its extension. + const islandModules = new Set(); + for (const module of lowerer.nodeIslands?.modules.values() ?? []) { + const file = real(module.file); + islandModules.add(file); + queue.push(file); + } if (queue.length === 0 && candidates.size === 0) return plans; const seen = new Set(); // Classes island code extends, by the compiled module that exports them. const extended = new Map(); while (queue.length > 0 && seen.size < MAX_ISLAND_FILES) { const file = queue.pop()!; - if (seen.has(file) || compiled.has(file) || !isJsFile(file)) continue; + if (seen.has(file) || compiled.has(file)) continue; + if (!isJsFile(file) && !islandModules.has(file)) continue; seen.add(file); let text: string; try { diff --git a/packages/compiler/src/node-islands/detect.test.ts b/packages/compiler/src/node-islands/detect.test.ts index 52b577da8..9723530d0 100644 --- a/packages/compiler/src/node-islands/detect.test.ts +++ b/packages/compiler/src/node-islands/detect.test.ts @@ -302,6 +302,26 @@ test("CommonJS object reads are recorded for the patch check (H3.4)", () => { "fs.readFileSync", ]); expect(reads("import * as fs from 'node:fs'; fs.readFileSync(p);", "mjs")).toEqual([]); + // Bracket notation: a literal key names the member, a computed one reads + // a member the scan cannot name. + expect(reads("const fs = require('fs'); fs['readFileSync'](p);")).toEqual(["fs.readFileSync"]); + expect(reads("const fs = require('fs'); fs[name](p);")).toEqual(["fs.*"]); + expect(reads("const fs = require('fs'); fs['readFileSync'] = f;")).toEqual([]); + // Destructuring reads each property it takes when it runs. + expect(reads("const { readFileSync } = require('node:fs');")).toEqual(["fs.readFileSync"]); + expect(reads("const fs = require('fs'); const { readFileSync: read, ...rest } = fs;")).toEqual([ + "fs.readFileSync", + "fs.*", + ]); + expect(reads("const { promises: { readFile } } = require('fs');")).toEqual([ + "fs.promises", + "fs.promises.readFile", + ]); + expect( + reads("import fs from 'node:fs'; let readFileSync; ({ readFileSync } = fs);", "mjs"), + ).toEqual(["fs.readFileSync"]); + // A namespace import is not the CommonJS object. + expect(reads("import * as fs from 'node:fs'; const { readFileSync } = fs;", "mjs")).toEqual([]); }); test("markers come back in source order with their locations", () => { diff --git a/packages/compiler/src/node-islands/detect.ts b/packages/compiler/src/node-islands/detect.ts index 926e1d6b8..6931af42b 100644 --- a/packages/compiler/src/node-islands/detect.ts +++ b/packages/compiler/src/node-islands/detect.ts @@ -162,6 +162,14 @@ function literalText(node: ts.Node | undefined): string | null { return null; } +/** A property name the scan can spell: an identifier, a string or number + * literal, or a computed literal key. */ +function staticKey(node: ts.Node): string | null { + if (ts.isIdentifier(node) || ts.isNumericLiteral(node)) return node.text; + if (ts.isComputedPropertyName(node)) return literalText(stripWrappers(node.expression)); + return literalText(node); +} + /** A specifier that folds at build time: a literal, or a concatenation of * literals. */ function isFoldableSpecifier(node: ts.Node): boolean { @@ -384,6 +392,7 @@ class ModuleScanner { return; } if (ts.isObjectBindingPattern(name)) { + this.recordCjsPatternReads(name, init); const base = init === undefined ? null : this.builtinOf(init); for (const element of name.elements.nodes) { if (!ts.isBindingElement(element)) continue; @@ -606,9 +615,11 @@ class ModuleScanner { private visit(node: ts.Node): void { if (ts.isCallExpression(node)) this.visitCall(node); else if (ts.isNewExpression(node)) this.visitNew(node); - else if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) + else if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) { this.visitWrite(node.left, node, "write"); - else if (ts.isDeleteExpression(node)) this.visitDelete(node); + if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) + this.recordCjsPatternReads(node.left, node.right); + } else if (ts.isDeleteExpression(node)) this.visitDelete(node); else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { if ( node.operator === ts.SyntaxKind.PlusPlusToken || @@ -633,6 +644,8 @@ class ModuleScanner { if ((text === "arguments.callee" || text === "arguments.caller") && !this.isStrict(node)) this.add("R2.4", "sloppy-arguments", `'${text}'`, node); this.recordCjsRead(node); + } else if (ts.isElementAccessExpression(node)) { + this.recordCjsRead(node); } else if (node.kind === ts.SyntaxKind.ThisKeyword) { this.visitThis(node); } else if (ts.isObjectLiteralExpression(node)) { @@ -651,13 +664,14 @@ class ModuleScanner { /** Records a read of a member of a builtin module's CommonJS object, so * a patch installed by an island module can be checked against it * (H3.4). The left of an assignment (and a delete or ++/-- target) is a - * write, not a read. */ - private recordCjsRead(node: ts.PropertyAccessExpression): void { + * write, not a read. A computed key reads a member the scan cannot name, + * so it is recorded as the whole module ("fs.*"). */ + private recordCjsRead(node: ts.PropertyAccessExpression | ts.ElementAccessExpression): void { const root = chainRoot(node); if (root === null || !this.cjsBuiltinObjects.has(root.text)) return; const module = this.builtinBindings.get(root.text); + if (module === undefined) return; const text = chainText(node); - if (module === undefined || text === null) return; const parent = node.parent; if (parent !== undefined) { if ( @@ -673,12 +687,78 @@ class ModuleScanner { ) return; } + this.pushCjsRead(text === null ? `${module}.*` : module + text.slice(root.text.length), node); + } + + private pushCjsRead(member: string, node: ts.Node): void { this.cjsReads.push({ - member: module + text.slice(root.text.length), + member, loc: { file: this.sf.fileName, start: ts.tokenStartOfNode(node), end: node.end }, }); } + /** The reads of a destructuring whose source is a builtin module's + * CommonJS object (`const { readFileSync } = require("fs")`, + * `({ readFileSync } = fs)`): each property it takes is read when the + * destructuring runs, so it sees a patch installed before. A rest element + * or a computed key reads members the scan cannot name ("fs.*"). */ + private recordCjsPatternReads(pattern: ts.Node, source: ts.Node | undefined): void { + if (source === undefined) return; + const value = stripWrappers(source); + if (!this.isCjsObjectValue(value)) return; + const module = this.builtinOf(value); + if (module === null || module.includes(".")) return; + this.recordPatternReads(pattern, module); + } + + private recordPatternReads(pattern: ts.Node, base: string): void { + const target = stripWrappers(pattern); + const elements: { + key: ts.Node | undefined; + value: ts.Node | undefined; + rest: boolean; + node: ts.Node; + }[] = []; + if (ts.isObjectBindingPattern(target)) { + for (const element of target.elements.nodes) { + if (!ts.isBindingElement(element)) continue; + elements.push({ + key: element.propertyName ?? element.name, + value: element.name, + rest: element.dotDotDotToken !== undefined, + node: element, + }); + } + } else if (ts.isObjectLiteralExpression(target)) { + for (const prop of target.properties.nodes) { + if (ts.isPropertyAssignment(prop)) + elements.push({ key: prop.name, value: prop.initializer, rest: false, node: prop }); + else if (ts.isShorthandPropertyAssignment(prop)) + elements.push({ key: prop.name, value: undefined, rest: false, node: prop }); + else if (ts.isSpreadAssignment(prop)) + elements.push({ key: undefined, value: undefined, rest: true, node: prop }); + } + } else return; + for (const element of elements) { + const key = element.rest || element.key === undefined ? null : staticKey(element.key); + if (key === null) { + this.pushCjsRead(`${base.split(".")[0]}.*`, element.node); + continue; + } + const member = `${base}.${key}`; + this.pushCjsRead(member, element.node); + // A nested pattern reads the member's own properties too. + if (element.value !== undefined) { + let inner = stripWrappers(element.value); + // `{ readFileSync = fallback }` (a default) and `{ a: { b } = {} }`. + if (ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.EqualsToken) + inner = stripWrappers(inner.left); + if (ts.isObjectBindingPattern(inner) || ts.isObjectLiteralExpression(inner)) + this.recordPatternReads(inner, member); + } + } + } + private visitSpecifier(spec: string, node: ts.Node): void { const builtin = builtinModuleName(spec); if (builtin === "vm") this.add("R1.1", "vm", "the 'vm' module", node); diff --git a/packages/compiler/src/node-islands/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index 8f443a5b5..f69f954eb 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -269,6 +269,25 @@ describe("the collected module tree", () => { expect(paths).not.toContain("helper.ts"); }); + test("named data stays inside the module's directory: links out of it are not followed", () => { + const outside = project({ "secrets/config.json": "{}", "token.txt": "secret\n" }); + const root = project({ + "src/main.ts": "", + "src/loader.ts": 'const names = ["config.json", "token.txt", "payload.txt"];', + "src/data/payload.txt": "shipped\n", + }); + symlinkSync(join(outside, "secrets"), join(root, "src/linked"), "dir"); + symlinkSync(join(outside, "token.txt"), join(root, "src/data/token.txt")); + // A link back up the tree must not loop the walk. + symlinkSync(join(root, "src"), join(root, "src/data/loop"), "dir"); + const collector = new NodeIslandModuleCollector(); + expect(collector.add(join(root, "src/main.ts"), "./loader.ts")).toBeNull(); + const paths = collector.tree()!.files.map((file) => file.path); + expect(paths).toContain("data/payload.txt"); + expect(paths.filter((path) => path.endsWith("config.json"))).toEqual([]); + expect(paths.filter((path) => path.endsWith("token.txt"))).toEqual([]); + }); + test("the manifest names relative paths only, and the data is checked against it", () => { const root = project({ "main.ts": "", "m.cjs": "exports.f = () => 1;" }); const collector = new NodeIslandModuleCollector(); diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index ead084088..e01a0b6e1 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -343,18 +343,32 @@ export class NodeIslandModuleCollector { * read to the wasm next to the patching module), and the island runs from * the shipped tree, so a file the module names by a string literal ships * with it. Files the source does not name stay out, and module sources - * ship through the traced walk, never as data. */ + * ship through the traced walk, never as data. The walk stays inside the + * module's directory: a symbolic link (to a file or a directory) that + * leads outside it is not followed, so nothing from elsewhere on the + * machine is embedded. */ private addNamedData(moduleFile: string, text: string): void { const names = new Set(); for (const token of moduleTokens(text)) if (token.kind === "string" && !token.value.includes("${")) names.add(basename(token.value)); if (names.size === 0) return; + const root = dirname(moduleFile); + const inside = (path: string): boolean => { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); + }; + const visited = new Set(); const walk = (dir: string): void => { + const real = trackedRealpath(dir); + if (real === null || !inside(real) || visited.has(real)) return; + visited.add(real); const entries = trackedAccessibleEntries(dir); if (entries === null) return; for (const file of entries.files) { const path = join(dir, file); if (!names.has(file) || TRACED.test(file) || path === moduleFile) continue; + const target = trackedRealpath(path); + if (target === null || !inside(target)) continue; this.files.add(path); } for (const sub of entries.directories) { @@ -362,7 +376,7 @@ export class NodeIslandModuleCollector { walk(join(dir, sub)); } }; - walk(dirname(moduleFile)); + walk(root); } /** A package: its whole directory, then its dependencies. */ diff --git a/packages/compiler/src/node-islands/type-strip.test.ts b/packages/compiler/src/node-islands/type-strip.test.ts new file mode 100644 index 000000000..77045a787 --- /dev/null +++ b/packages/compiler/src/node-islands/type-strip.test.ts @@ -0,0 +1,43 @@ +import { afterAll, expect, test } from "vitest"; +import type * as ts from "../frontend/ts7/adapter.js"; +import { closeSourceParser, parseSourceFile } from "../frontend/ts7/source-parser-node.js"; +import { stripProblem } from "./type-strip.js"; + +afterAll(() => closeSourceParser()); + +let serial = 0; +/** The SC8006 construct for a TypeScript module, or null. None of these + * cases needs the checker (only a plain named import of a type does). */ +function problem(source: string): string | null { + const sf = parseSourceFile(`/virtual/s${serial++}.ts`, source, "ts"); + const program = { + getTypeChecker(): never { + throw new Error("the checker is not needed here"); + }, + } as unknown as ts.Program; + return stripProblem(program, sf)?.construct ?? null; +} + +test("a relative specifier Node resolves needs its file extension", () => { + expect(problem('import { a } from "./a";')).toBe( + "the relative import './a' without a file extension", + ); + expect(problem('export { a } from "./a";')).toBe( + "the relative export from './a' without a file extension", + ); + expect(problem('const m = await import("./a");')).toBe( + "the relative import() of './a' without a file extension", + ); + expect(problem('import "./a.ts";\nexport * from "./b.js";')).toBeNull(); +}); + +test("type-only imports and exports are erased, so their specifiers need no extension", () => { + expect(problem('import type { A } from "./types";')).toBeNull(); + expect(problem('import type Default from "./types";')).toBeNull(); + expect(problem('export type { A } from "./types";')).toBeNull(); + expect(problem('export type * from "./types";')).toBeNull(); + // An inline `type` element leaves the declaration, which Node still loads. + expect(problem('import { type A } from "./types";')).toBe( + "the relative import './types' without a file extension", + ); +}); diff --git a/packages/compiler/src/node-islands/type-strip.ts b/packages/compiler/src/node-islands/type-strip.ts index 4e8c61b45..1394d831f 100644 --- a/packages/compiler/src/node-islands/type-strip.ts +++ b/packages/compiler/src/node-islands/type-strip.ts @@ -124,15 +124,16 @@ export function stripProblem(program: ts.Program, sf: ts.SourceFile): StripProbl return undefined; } if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + // `import type` is erased whole, so Node never resolves its specifier. + if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) return undefined; const spec = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null; if (spec !== null && isRelativeSpecifier(spec) && !hasResolvedExtension(spec)) { report(`the relative import '${spec}' without a file extension`); return undefined; } - const clause = node.importClause; if ( clause === undefined || - clause.phaseModifier === ts.SyntaxKind.TypeKeyword || clause.namedBindings === undefined || !ts.isNamedImports(clause.namedBindings) ) @@ -152,7 +153,9 @@ export function stripProblem(program: ts.Program, sf: ts.SourceFile): StripProbl } if (ts.isExportDeclaration(node)) { const specifier = node.moduleSpecifier; + // `export type { T } from` and `export type * from` are erased whole. if ( + !node.isTypeOnly && specifier !== undefined && ts.isStringLiteral(specifier) && isRelativeSpecifier(specifier.text) && diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 326f6530a..6f4450c41 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -501,6 +501,59 @@ export const patched = Kind.A; expect(stripped).toBeDefined(); expect(stripped).toContain("enum"); }); + + test("a TypeScript patcher shares the program modules it imports with native code", async () => { + // state.ts is a stateful program module that both the native entry and + // the islanded patcher import: Node runs one copy of it, so the island + // must reach the program's copy, not one of its own. + const dir = copyCorpus( + "shared-state", + `import { bump, count } from "./state.ts"; +import { patchAndBump } from "./statepatch.ts"; +bump(); +const fromIsland = patchAndBump(); +console.log(fromIsland, count()); +`, + { + "state.ts": `let value = 0; +export function bump(): number { + value++; + return value; +} +export function count(): number { + return value; +} +`, + "statepatch.ts": `import { createRequire } from "node:module"; +import { bump } from "./state.ts"; +const require = createRequire(import.meta.url); +const fs = require("fs") as typeof import("fs"); +const original = fs.readFileSync; +export function patchAndBump(): number { + fs.readFileSync = original; + return bump(); +} +`, + }, + ); + const result = await buildDir(dir, "main"); + expect(messages(result)).toEqual([]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.islands?.modules.map((file) => basename(file))).toEqual(["statepatch.ts"]); + // Node strips types only outside node_modules, and the shared island + // cache of these tests lives below the repository's node_modules. + const islandCache = mkdtempSync(join(tmpdir(), "scriptc-island-cache-")); + try { + const out = await run(join(dir, "main"), [], { + ...process.env, + XDG_CACHE_HOME: islandCache, + }); + expect(out).toEqual({ stdout: "2 2\n", stderr: "", code: 0 }); + } finally { + rmSync(islandCache, { recursive: true, force: true }); + } + }, 300_000); }); }); diff --git a/tests/harness/test262-runner.test.ts b/tests/harness/test262-runner.test.ts index 46a0b5fa0..e1eb96a5b 100644 --- a/tests/harness/test262-runner.test.ts +++ b/tests/harness/test262-runner.test.ts @@ -1,7 +1,10 @@ +import { join } from "node:path"; +import ts from "typescript"; import { expect, test } from "vitest"; import { boundedRun } from "../test262/execute.js"; import { compileFailureStatus, + directory, exclusion, matchesParseNegative, metadata, @@ -132,6 +135,40 @@ test("a lifted script-environment variant keeps the script shape inside its isla expect(scriptEnvironment("export default 1;")).toBe(false); }); +test("the harness declares each name once and exports every name the island prelude reads", () => { + const file = join(directory, "harness.ts"); + const program = ts.createProgram([file], { + noEmit: true, + strict: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + allowImportingTsExtensions: true, + }); + const sf = program.getSourceFile(file)!; + // Duplicate identifier, redeclared block-scoped variable or export, and + // duplicate function implementation: each fails every executable variant. + const duplicates = program + .getSemanticDiagnostics(sf) + .filter((d) => [2300, 2323, 2393, 2451].includes(d.code)) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")); + expect(duplicates).toEqual([]); + const checker = program.getTypeChecker(); + const exported = new Set( + checker.getExportsOfModule(checker.getSymbolAtLocation(sf)!).map((symbol) => symbol.name), + ); + const prelude = prepareIsland(source("description: names"), true, "MARK", [], true); + const read = [...prelude.matchAll(/(? match[1]!, + ); + expect(read.length).toBeGreaterThan(0); + expect(read.filter((name) => !exported.has(name))).toEqual([]); + // The bare compareArray and the assertion are different functions. + expect(prelude).toContain("assert.compareArray = harness.assertCompareArray;"); + expect(prelude).toContain("const compareArray = harness.compareArray;"); + expect(prelude).toContain("globalThis.compareArray = compareArray;"); +}); + test("the property helper only admits direct inline descriptor checks", () => { for (const body of [ "verifyProperty({}, 'x', { value: 1, configurable: false });", diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index ab47fc96f..577d3b3f0 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -124,8 +124,10 @@ export namespace assert { // function crosses the bridge as a callable, but the namespace members // attached to it do not (they are properties of the native function object). // Test262's `assert` must be callable and carry the helpers, so each helper -// is exported on its own and the island wrapper builds the object. +// is exported on its own and the island wrapper builds the object. The +// assertion form of compareArray takes its own name: `compareArray` is the +// bare function above. export const sameValue = assert.sameValue; export const notSameValue = assert.notSameValue; export const assertThrows = assert.throws; -export const compareArray = assert.compareArray; +export const assertCompareArray = assert.compareArray; diff --git a/tests/test262/support.mjs b/tests/test262/support.mjs index f4d8eb32c..b907f3e19 100644 --- a/tests/test262/support.mjs +++ b/tests/test262/support.mjs @@ -249,7 +249,8 @@ export function prepareIsland(source, asyncTest = false, marker = completion, in "assert.sameValue = harness.sameValue;", "assert.notSameValue = harness.notSameValue;", "assert.throws = harness.assertThrows;", - "assert.compareArray = harness.compareArray;", + "assert.compareArray = harness.assertCompareArray;", + "const compareArray = harness.compareArray;", "const Test262Error = harness.Test262Error;", ...(asyncTest ? ["const $DONE = harness.$DONE;"] : []), ]; @@ -258,7 +259,12 @@ export function prepareIsland(source, asyncTest = false, marker = completion, in // top-level `this` is the global object and its top-level `var` bindings are // global properties. The island therefore replays the body as script code, // and the module carries it only as a dead copy the pre-scan can island. - const globals = ["globalThis.assert = assert;", "globalThis.Test262Error = Test262Error;"]; + const globals = [ + "globalThis.assert = assert;", + "globalThis.compareArray = compareArray;", + "globalThis.Test262Error = Test262Error;", + ...(asyncTest ? ["globalThis.$DONE = $DONE;"] : []), + ]; return [ 'const { readFileSync } = require("node:fs");', 'const vm = require("node:vm");', From 33018e9d8b8b5b022d2358060ff7b25f541c71f7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:47:27 -0500 Subject: [PATCH 23/44] Compare a held generic function with scalars without the identity check A generic declaration held at several signatures and read through unknown was refused with SC1090 when compared with a string, number or boolean. A function is unequal to such a value whichever instance it is, so a comparison with an operand the checker types as a string, number, boolean, bigint or symbol is not recorded for the identity check. --- .../src/frontend/lowering/generic-functions.ts | 16 ++++++++++++++++ .../generic-identity-unknown-scalars.ts.json | 6 ++++++ tests/corpus/generic-identity-unknown-scalars.ts | 16 ++++++++++++++++ .../generic-identity-unknown-scalars.ts.json | 1 + 4 files changed, 39 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown-scalars.ts.json create mode 100644 tests/corpus/generic-identity-unknown-scalars.ts create mode 100644 tests/harness/corpus-costs/generic-identity-unknown-scalars.ts.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 404bca4a5..da85cee6c 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -1724,6 +1724,9 @@ export function noteDynamicIdentityComparison( left: ts.Node, right: ts.Node, ): void { + // A function compared with a string, number, boolean, bigint or symbol + // is unequal whichever instance it is, as in JavaScript. + if (isPrimitiveTyped(lowerer, left) || isPrimitiveTyped(lowerer, right)) return; // The only work done while lowering. No instance count can gate this: // a value reference's instance is interned when its binding settles, // which can be later than the comparison that reads it, so the notes @@ -1732,6 +1735,19 @@ export function noteDynamicIdentityComparison( lowerer.dynIdentityComparisons.push({ node, left, right }); } +const PRIMITIVE_TYPE_FLAGS = + ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.ESSymbolLike; + +/** Whether the checker types `node` as a string, number, boolean, bigint or + * symbol (a union with any other member is not). */ +function isPrimitiveTyped(lowerer: Lowerer, node: ts.Node): boolean { + return (lowerer.typeOf(node).flags & PRIMITIVE_TYPE_FLAGS) !== 0; +} + /** Whether an expression could name a binding at all — an identifier or a * property access, through parentheses and casts. A literal, a call, an * operator expression: nothing to resolve. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown-scalars.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown-scalars.ts.json new file mode 100644 index 000000000..1f3da1b97 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-identity-unknown-scalars.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-identity-unknown-scalars.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-identity-unknown-scalars.ts b/tests/corpus/generic-identity-unknown-scalars.ts new file mode 100644 index 000000000..419837ad5 --- /dev/null +++ b/tests/corpus/generic-identity-unknown-scalars.ts @@ -0,0 +1,16 @@ +// A generic declaration held at two signatures, read through `unknown`, +// compares unequal to strings, numbers and booleans whichever instance the +// value holds, as in JavaScript. +type Fn = (x: number) => number; +function ident(x: T): T { + return x; +} +const a: Fn = ident; +const b: (x: string) => string = ident; +const held: unknown = a; +const label = "ident"; +const count = 0; +const flag = true; +console.log(held === label, held !== count, held === flag, label === held); +console.log(held === "ident", held !== 1, false === held); +console.log(a(2), b("s")); diff --git a/tests/harness/corpus-costs/generic-identity-unknown-scalars.ts.json b/tests/harness/corpus-costs/generic-identity-unknown-scalars.ts.json new file mode 100644 index 000000000..bd74dc19c --- /dev/null +++ b/tests/harness/corpus-costs/generic-identity-unknown-scalars.ts.json @@ -0,0 +1 @@ +{ "plain": 8425, "san": 6439 } From 58b650dca706b9c56acdf33d0cf97530f25cf066 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 19:48:40 -0700 Subject: [PATCH 24/44] Record the sloppy island corpus program's preflight on the current base The recorded entry came from the branch's older base. The preflight of lookup.js no longer reports the two `Cannot find name` errors for the names a `with` resolves against its record (`last` in describe, `inner` in the nested with); the `with`-in-strict-mode errors and the rest of the entry are unchanged. The corpus differential still matches Node on the program's output, so this records the behavior the tree has today. Co-Authored-By: Claude Opus 5.5 --- .../8427-sloppy-with-island/main.js.json | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json index cb38c0ce6..eec7c13b3 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8427-sloppy-with-island/main.js.json @@ -22,15 +22,6 @@ "end": 207 } }, - { - "code": "SC0001", - "message": "Cannot find name 'last'.", - "loc": { - "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", - "start": 252, - "end": 256 - } - }, { "code": "SC0001", "message": "'with' statements are not allowed in strict mode.", @@ -93,15 +84,6 @@ "start": 548, "end": 552 } - }, - { - "code": "SC0001", - "message": "Cannot find name 'inner'.", - "loc": { - "file": "/tests/corpus/8427-sloppy-with-island/lookup.js", - "start": 576, - "end": 581 - } } ] } From 7ab5485c9929a0fef3d1b303445c7bd57e5dd916 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 23:33:27 -0500 Subject: [PATCH 25/44] Keep program island modules eager and ship only the data paths they name A program module that runs in a Node island loads with nodeIsland.require instead of nodeIsland.defer, so the build-time probe never evaluates the program's own source. Deferral and its probe stay with island packages. A program island module ships the data files it names by their exact path relative to its directory, including consecutive literal arguments of one call (join(here, "fallback", "payload.txt")). A file elsewhere in the tree with the same name no longer ships. Files the source does not name ship when scriptc.nodeIslands.files declares them (paths or globs relative to the package.json); a declared path without a file is SC8004. The island module order in the lowering is cached in a Map the lowering owns instead of a WeakMap. --- .../frontend/lowering/lower-node-island.ts | 11 +- .../compiler/src/frontend/lowering/lowerer.ts | 24 +++- packages/compiler/src/frontend/pipeline.ts | 5 +- .../compiler/src/node-islands/boundary.ts | 10 ++ .../compiler/src/node-islands/modules.test.ts | 81 ++++++++++- packages/compiler/src/node-islands/modules.ts | 133 ++++++++++++++---- .../compiler/src/node-islands/snapshot.ts | 6 +- packages/compiler/src/project-config.test.ts | 11 ++ packages/compiler/src/project-config.ts | 24 ++++ tests/harness/node-island.test.ts | 92 +++++++++++- 10 files changed, 358 insertions(+), 39 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 742b47d4b..b2d0d99e5 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -48,6 +48,7 @@ import { import { crossingOf, isForeignType, + isIslandProgramModule, isIslandSubclassDecl, markIslandSubclass, islandOwnerOfFile, @@ -402,8 +403,16 @@ function loadIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr { /** A static import's load: `nodeIsland.defer(path, snapshot)`, which the * build decides after lowering (node-islands/snapshot.ts, scope-B NI-7): * a module proved effect-free at load keeps it with its interface snapshot - * and evaluates on first use; any other becomes nodeIsland.require. */ + * and evaluates on first use; any other becomes nodeIsland.require. + * + * A program module that runs in an island loads eagerly + * (`nodeIsland.require`): deciding a deferral means evaluating the module + * in the build-time probe, and the build never runs the program's own + * source. */ function deferIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr { + const islands = lowerer.nodeIslands; + if (islands !== null && isIslandProgramModule(islands, path)) + return loadIslandModule(lowerer, path, loc); return { kind: "libCall", fn: "nodeIsland.defer", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ef9d5c31c..cedb12e6c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -293,6 +293,7 @@ import { intersectionTypeDiag, noLoweringDiag, overloadedSignatureTypeDiag, + projectConfigDiag, recordShapeMismatchDiag, stdlibSnapshotValueUseDiag, unsupportedNativeApiDiag, @@ -786,7 +787,8 @@ import { maybePromiseUnionOf, promiseArmedUnionOf, } from "./maybe-promise.js"; -import { NodeIslandModuleCollector } from "../../node-islands/modules.js"; +import { NodeIslandModuleCollector, declaredIslandFiles } from "../../node-islands/modules.js"; +import { readProjectConfig } from "../../project-config.js"; /** Entry function name. '%' cannot appear in a TS identifier, so a user * function can never collide with it (mangling is injective per prefix). */ @@ -9850,6 +9852,7 @@ export class Lowerer { refuseX509DynValues(this, functions); markInferredFactChecks(functions, this.program); const sloppyDynWrites = sloppyDynamicWrites(this, functions); + if (this.usesNodeIsland) this.addDeclaredIslandFiles(); const module: IrModule | null = this.diags.length > 0 ? null @@ -11145,6 +11148,25 @@ export class Lowerer { this.diags.push(d); } + /** `scriptc.nodeIslands.files`: files the program's island modules read + * without naming their path ship with those modules. A declared path + * without a file is SC8004. */ + private addDeclaredIslandFiles(): void { + if (this.nodeIslands === null || this.nodeIslands.modules.size === 0) return; + const declared = declaredIslandFiles(readProjectConfig(this.entry.fileName)); + const loc: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 }; + for (const path of declared.missing) + this.pushDiag( + projectConfigDiag( + "scriptc.nodeIslands.files", + `names ${path}, which does not exist`, + "remove the entry or create the file", + loc, + ), + ); + for (const file of declared.files) this.nodeIslandModules.addDeclaredFile(file); + } + /** The module's Node island record: the collected module tree, with each * call's caller literal and each load's path literal set to its path in * that tree. The executable build fills in the libnode pack (index.ts). */ diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index cbd1fb238..aba2dcd8b 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -920,8 +920,9 @@ function loadFrontend( lower: (opts, valid) => { // The module order without the island modules, computed once per load: // a reload keeps the array the lowering appends its dynamic import - // modules to. - const islandFiltered = new WeakMap(); + // modules to. The map lives as long as this lowering, which owns the + // loads it keys. + const islandFiltered = new Map(); const lowerOrder = (l: typeof finalLoad): SourceFile[] => { if (islandModules === null) return l.moduleOrder; let kept = islandFiltered.get(l.moduleOrder); diff --git a/packages/compiler/src/node-islands/boundary.ts b/packages/compiler/src/node-islands/boundary.ts index bd317e23f..41f4ab845 100644 --- a/packages/compiler/src/node-islands/boundary.ts +++ b/packages/compiler/src/node-islands/boundary.ts @@ -14,6 +14,7 @@ * calls. A union is foreign when every arm that is not null or undefined * is foreign (the dyn representation holds null and undefined). */ import * as ts from "../frontend/ts7/adapter.js"; +import { trackedRealpath } from "../frontend/input-tracker.js"; import { packageDirOf } from "./origin.js"; /** One package the build runs in a Node island. */ @@ -60,6 +61,15 @@ export function islandModuleOfFile(islands: NodeIslandSet, file: string): Island return islands.modules.get(file) ?? islands.modules.get(normalizePath(file)) ?? null; } +/** Whether `file` (a path as the checker spells it, or its real path) is a + * program module of the island set. */ +export function isIslandProgramModule(islands: NodeIslandSet, file: string): boolean { + if (islandModuleOfFile(islands, file) !== null) return true; + for (const module of islands.modules.values()) + if ((trackedRealpath(module.file) ?? module.file) === file) return true; + return false; +} + /** The island (package or module) that owns a declaration or JavaScript * file, or null. Modules first: a user module is never inside a package. */ export function islandOwnerOfFile( diff --git a/packages/compiler/src/node-islands/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index f69f954eb..499a59e78 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -2,8 +2,11 @@ import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, test } from "vitest"; +import { readProjectConfig } from "../project-config.js"; import { NodeIslandModuleCollector, + declaredIslandFiles, + namedDataPaths, nodeIslandManifest, readNodeIslandModules, requireModuleSpecifiers, @@ -19,6 +22,26 @@ function project(files: Record): string { return root; } +describe("data paths a module names", () => { + test("string literals, and consecutive literal arguments joined", () => { + const names = namedDataPaths(` + const a = readFileSync(join(here, "fallback", "payload.txt")); + const b = "assets/config.json"; + const c = resolve(__dirname, "x", base, "y"); + // "comment.txt" + const d = \`\${dir}/template.txt\`; + `); + expect([...names].sort()).toEqual([ + "assets/config.json", + "fallback", + "fallback/payload.txt", + "payload.txt", + "x", + "y", + ]); + }); +}); + describe("module specifiers in JavaScript", () => { test("string-literal loads in every form", () => { const scan = scanModuleSpecifiers(` @@ -273,7 +296,8 @@ describe("the collected module tree", () => { const outside = project({ "secrets/config.json": "{}", "token.txt": "secret\n" }); const root = project({ "src/main.ts": "", - "src/loader.ts": 'const names = ["config.json", "token.txt", "payload.txt"];', + "src/loader.ts": + 'const names = ["linked/config.json", "data/token.txt", "data/payload.txt", "data/loop/data/payload.txt"];', "src/data/payload.txt": "shipped\n", }); symlinkSync(join(outside, "secrets"), join(root, "src/linked"), "dir"); @@ -286,6 +310,61 @@ describe("the collected module tree", () => { expect(paths).toContain("data/payload.txt"); expect(paths.filter((path) => path.endsWith("config.json"))).toEqual([]); expect(paths.filter((path) => path.endsWith("token.txt"))).toEqual([]); + expect(paths.filter((path) => path.includes("loop"))).toEqual([]); + }); + + test("only the exact path a module names ships, never a file of the same name elsewhere", () => { + const outside = project({ "outside.txt": "private\n" }); + const root = project({ + "src/main.ts": "", + "src/loader.cjs": [ + 'const fs = require("node:fs");', + 'const config = fs.readFileSync(__dirname + "/" + "assets/config.json");', + 'const up = "../notes.txt";', + `const away = "${join(outside, "outside.txt")}";`, + 'const deps = "node_modules/dep/config.json";', + ].join("\n"), + "src/assets/config.json": "{}", + "src/private/config.json": '{ "token": "secret" }', + "src/config.json": '{ "token": "secret" }', + "src/node_modules/dep/config.json": "{}", + "notes.txt": "beside the source directory\n", + }); + const collector = new NodeIslandModuleCollector(); + expect(collector.add(join(root, "src/main.ts"), "./loader.cjs")).toBeNull(); + const paths = collector.tree()!.files.map((file) => file.path); + expect(paths.filter((path) => !path.endsWith(".cjs"))).toEqual(["assets/config.json"]); + }); + + test("files scriptc.nodeIslands.files declares ship with the island modules", () => { + const root = project({ + "package.json": JSON.stringify({ + scriptc: { nodeIslands: { files: ["data/*.json", "docs/readme.txt", "absent.bin"] } }, + }), + "main.ts": "", + "data/a.json": "{}", + "data/b.json": "{}", + "data/c.txt": "", + "data/nested/d.json": "{}", + "docs/readme.txt": "", + "node_modules/x/data/e.json": "{}", + }); + const declared = declaredIslandFiles(readProjectConfig(join(root, "main.ts"))); + expect(declared.files).toEqual([ + join(root, "data/a.json"), + join(root, "data/b.json"), + join(root, "docs/readme.txt"), + ]); + expect(declared.missing).toEqual(["absent.bin"]); + const collector = new NodeIslandModuleCollector(); + expect(collector.add(join(root, "main.ts"), "./data/a.json")).toBeNull(); + for (const file of declared.files) collector.addDeclaredFile(file); + expect(collector.tree()!.files.map((file) => file.path)).toEqual([ + "data/a.json", + "data/b.json", + "docs/readme.txt", + "package.json", + ]); }); test("the manifest names relative paths only, and the data is checked against it", () => { diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index e01a0b6e1..ae97de055 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -40,7 +40,9 @@ import { trackedRealpath, } from "../frontend/input-tracker.js"; import type { IrNodeIslandModules } from "../ir/ir.js"; +import type { ProjectConfig } from "../project-config.js"; import { packageDirOf } from "./origin.js"; +import { globMatch } from "./policy.js"; export type NodeIslandModuleTree = IrNodeIslandModules; type NodeIslandModuleFile = IrNodeIslandModules["files"][number]; @@ -341,42 +343,39 @@ export class NodeIslandModuleCollector { /** Data files beside a program module that runs in an island: the module * resolves them from its own directory (photon redirects a package's * read to the wasm next to the patching module), and the island runs from - * the shipped tree, so a file the module names by a string literal ships - * with it. Files the source does not name stay out, and module sources - * ship through the traced walk, never as data. The walk stays inside the - * module's directory: a symbolic link (to a file or a directory) that - * leads outside it is not followed, so nothing from elsewhere on the - * machine is embedded. */ + * the shipped tree, so a file the module names by its path relative to + * the module's directory ships with it (namedDataPaths). Only the exact + * path ships: a file elsewhere in the tree with the same name stays out, + * and so does every file the source does not name; such files ship when + * `scriptc.nodeIslands.files` declares them (addDeclaredFile). Module + * sources ship through the traced walk, never as data. A named path + * stays inside the module's directory: one that leads outside it, + * through `..` or a symbolic link, or into node_modules or .git, is not + * shipped, so nothing from elsewhere on the machine is embedded. */ private addNamedData(moduleFile: string, text: string): void { - const names = new Set(); - for (const token of moduleTokens(text)) - if (token.kind === "string" && !token.value.includes("${")) names.add(basename(token.value)); - if (names.size === 0) return; const root = dirname(moduleFile); const inside = (path: string): boolean => { const rel = relative(root, path); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); }; - const visited = new Set(); - const walk = (dir: string): void => { - const real = trackedRealpath(dir); - if (real === null || !inside(real) || visited.has(real)) return; - visited.add(real); - const entries = trackedAccessibleEntries(dir); - if (entries === null) return; - for (const file of entries.files) { - const path = join(dir, file); - if (!names.has(file) || TRACED.test(file) || path === moduleFile) continue; - const target = trackedRealpath(path); - if (target === null || !inside(target)) continue; - this.files.add(path); - } - for (const sub of entries.directories) { - if (sub === "node_modules" || sub === ".git") continue; - walk(join(dir, sub)); - } - }; - walk(root); + for (const name of namedDataPaths(text)) { + if (isAbsolute(name) || name.includes("\0")) continue; + const path = resolve(root, name); + if (!inside(path) || path === moduleFile || TRACED.test(path)) continue; + const parts = relative(root, path).split(sep); + if (parts.includes("node_modules") || parts.includes(".git")) continue; + if (!trackedFileExists(path)) continue; + const target = trackedRealpath(path); + if (target === null || !inside(target)) continue; + // A link inside the directory stays a link in the tree (placed). + this.files.add(this.placed(path)); + } + } + + /** A file `scriptc.nodeIslands.files` declares for the island modules of + * the program (declaredIslandFiles): shipped at its path in the tree. */ + addDeclaredFile(file: string): void { + this.files.add(this.placed(file)); } /** A package: its whole directory, then its dependencies. */ @@ -632,6 +631,78 @@ export interface ModuleSpecifierScan { computed: { at: number; form: string }[]; } +/** The files `scriptc.nodeIslands.files` declares: each path, relative to + * the package.json, and the files under the package.json's directory each + * glob matches (node_modules and .git directories excluded). `missing` + * lists the paths without a file. */ +export function declaredIslandFiles(config: ProjectConfig): { files: string[]; missing: string[] } { + const patterns = config.nodeIslands.files; + if (patterns.length === 0) return { files: [], missing: [] }; + const base = config.manifest === null ? process.cwd() : dirname(config.manifest); + const files = new Set(); + const missing: string[] = []; + const globs: string[] = []; + for (const pattern of patterns) { + const path = resolve(base, pattern); + if (!/[*?]/.test(pattern)) { + if (trackedFileExists(path)) files.add(path); + else missing.push(pattern); + } else globs.push(portable(path)); + } + if (globs.length > 0) { + const walk = (dir: string): void => { + const entries = trackedAccessibleEntries(dir); + if (entries === null) return; + for (const file of entries.files) { + const path = join(dir, file); + if (globs.some((glob) => globMatch(glob, portable(path)))) files.add(path); + } + for (const sub of entries.directories) + if (sub !== "node_modules" && sub !== ".git") walk(join(dir, sub)); + }; + walk(base); + } + return { files: [...files].sort(), missing }; +} + +/** The relative paths a module's source names by string literals: each + * literal (`"data/table.json"`), and the literals passed as consecutive + * arguments of one call joined with `/`, as `path.join(here, "fallback", + * "payload.txt")` names `fallback/payload.txt`. Comments and template + * literals with substitutions name nothing. */ +export function namedDataPaths(text: string): Set { + const tokens = moduleTokens(text); + const names = new Set(); + let run: string[] = []; + const end = (): void => { + if (run.length > 1) names.add(run.join("/")); + run = []; + }; + for (let k = 0; k < tokens.length; k++) { + const t = tokens[k]!; + if (t.kind === "string") { + if (t.value.includes("${")) { + end(); + continue; + } + names.add(t.value); + const before = tokens[k - 1]; + const after = tokens[k + 1]; + const argument = + before?.kind === "punct" && + (before.value === "(" || before.value === ",") && + after?.kind === "punct" && + (after.value === ")" || after.value === ","); + if (!argument) end(); + else run.push(t.value); + continue; + } + if (t.kind !== "punct" || t.value !== ",") end(); + } + end(); + return names; +} + /** The string-literal arguments of `requireModule(...)` calls in source * code, comments and template text excluded. */ export function requireModuleSpecifiers(text: string): string[] { diff --git a/packages/compiler/src/node-islands/snapshot.ts b/packages/compiler/src/node-islands/snapshot.ts index 4da7cee06..66c70a24d 100644 --- a/packages/compiler/src/node-islands/snapshot.ts +++ b/packages/compiler/src/node-islands/snapshot.ts @@ -1,8 +1,10 @@ /* Deferred island modules: the decision, the build-time probe and the * interface snapshot (scope-B NI-7, DESIGN.md §2.7, §5.4). * - * Lowering emits `nodeIsland.defer(path, "")` for every static import of an - * island module (lower-node-island.ts). After lowering, once the build + * Lowering emits `nodeIsland.defer(path, "")` for every static import of a + * module of an island package (lower-node-island.ts); a program module that + * runs in an island loads eagerly, so the probe never evaluates the + * program's own source. After lowering, once the build * knows the libnode pack the executable will load, finalizeDeferredIslands * decides each one: * diff --git a/packages/compiler/src/project-config.test.ts b/packages/compiler/src/project-config.test.ts index ddf93d79d..7282390ac 100644 --- a/packages/compiler/src/project-config.test.ts +++ b/packages/compiler/src/project-config.test.ts @@ -17,6 +17,7 @@ test("a package.json without a scriptc key has the default configuration", () => expect(c.nodeIslands).toEqual({ auto: true, modules: [], + files: [], node: null, nodeVersion: null, packaging: "embedded", @@ -103,6 +104,7 @@ test("nodeIslands settings", () => { nodeIslands: { auto: false, modules: ["src/plugins/*.ts"], + files: ["data/*.json"], node: "/usr/local/bin/node", nodeVersion: ">=24.13.1", packaging: "beside", @@ -115,6 +117,7 @@ test("nodeIslands settings", () => { expect(c.nodeIslands).toEqual({ auto: false, modules: ["src/plugins/*.ts"], + files: ["data/*.json"], node: "/usr/local/bin/node", nodeVersion: ">=24.13.1", packaging: "beside", @@ -133,6 +136,14 @@ test("publicEnv must be an array of variable names", () => { expect(c.nodeIslands.publicEnv).toEqual([]); }); +test("files must be an array of paths or globs", () => { + const c = config({ nodeIslands: { files: "data/config.json" } }); + expect(c.diagnostics.map((d) => d.message)).toEqual([ + `package.json 'scriptc.nodeIslands.files' must be an array of file paths or globs, not "data/config.json"`, + ]); + expect(c.nodeIslands.files).toEqual([]); +}); + test("unknown keys and values are SC8004, located at the key", () => { const text = JSON.stringify( { diff --git a/packages/compiler/src/project-config.ts b/packages/compiler/src/project-config.ts index 9b52993db..653b9678d 100644 --- a/packages/compiler/src/project-config.ts +++ b/packages/compiler/src/project-config.ts @@ -14,6 +14,8 @@ * "zod": { "shapes": false } // no shape-inference overlay * }, * "nodeIslands": { "auto": true, "modules": ["src/plugins/*.ts"] }, + * // files island modules of the program read, shipped with them + * // (node-islands/modules.ts): "nodeIslands": { "files": ["data/*.json"] } * "wasm": ["build/module.wasm"], * "typePatches": { // frontend/type-patches/layer.ts * "commander@^15.0.0": { @@ -60,6 +62,10 @@ export interface NodeIslandsSetting { /** User modules (paths or globs relative to the package.json) that run * in Node. */ modules: string[]; + /** Files (paths or globs relative to the package.json) that ship with + * the island modules of the program, beside the files their source names + * by path (node-islands/modules.ts). */ + files: string[]; /** The `node` executable recorded into the binary. */ node: string | null; /** A semver range checked at the helper handshake. */ @@ -131,6 +137,7 @@ const PACKAGE_SETTING_KEYS = [...Object.keys(PACKAGE_OBJECT_KEYS), "shapes"]; const NODE_ISLANDS_KEYS = [ "auto", "modules", + "files", "node", "nodeVersion", "packaging", @@ -154,6 +161,7 @@ function defaultNodeIslands(): NodeIslandsSetting { return { auto: true, modules: [], + files: [], node: null, nodeVersion: null, packaging: "embedded", @@ -344,6 +352,22 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi `write "modules": ["src/plugins/loader.ts"]`, ); } else setting.modules = list; + } else if (key === "files") { + const list: string[] = []; + let ok = Array.isArray(v); + if (Array.isArray(v)) { + for (const entry of v as unknown[]) { + if (typeof entry === "string" && entry !== "") list.push(entry); + else ok = false; + } + } + if (!ok) { + problem( + ["nodeIslands", key], + `must be an array of file paths or globs, not ${shown(v)}`, + `write "files": ["data/config.json"]`, + ); + } else setting.files = list; } else if (key === "publicEnv") { const list: string[] = []; let ok = Array.isArray(v); diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 6f4450c41..46e64cfc2 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -23,7 +23,7 @@ import { import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; import { compile, deserializeModule } from "@scriptc/compiler"; import { definedFunctionNames, nodeIslandSymbols } from "../../scripts/zero-cost-compare.mjs"; import { libnodePack } from "./libnode.js"; @@ -555,8 +555,98 @@ export function patchAndBump(): number { } }, 300_000); }); + + // The build-time probe evaluates modules to decide their deferral. A + // program module that runs in an island loads eagerly, so the build never + // evaluates the program's own source; the executable evaluates it when + // the entry requires it. + test("the build does not evaluate a program module that runs in an island", async () => { + const dir = sloppyModuleProject("probe"); + const cache = mkdtempSync(join(tmpdir(), "scriptc-sloppy-probe-cache-")); + const islandCache = mkdtempSync(join(tmpdir(), "scriptc-island-cache-")); + const previous = process.env["SCRIPTC_CACHE_DIR"]; + process.env["SCRIPTC_CACHE_DIR"] = cache; + try { + const result = await compile(join(dir, "main.js"), { + outPath: join(dir, "main"), + outDir: dir, + sanitize, + libnode: pack!, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(result.islands?.modules.map((file) => basename(file))).toEqual(["sloppy.js"]); + expect(existsSync(join(dir, "evaluated.txt"))).toBe(false); + const out = await run(join(dir, "main"), [], { ...process.env, XDG_CACHE_HOME: islandCache }); + expect(out).toEqual({ stdout: "2\n", stderr: "", code: 0 }); + expect(existsSync(join(dir, "evaluated.txt"))).toBe(false); + } finally { + if (previous === undefined) delete process.env["SCRIPTC_CACHE_DIR"]; + else process.env["SCRIPTC_CACHE_DIR"] = previous; + rmSync(cache, { recursive: true, force: true }); + rmSync(islandCache, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }, 300_000); }); +/** A CommonJS program whose entry requires a sloppy-mode module (a `with` + * statement islands it under the default `--node-islands=auto`). The + * module writes evaluated.txt beside itself when it evaluates. */ +function sloppyModuleProject(name: string): string { + const dir = mkdtempSync(join(tmpdir(), `scriptc-sloppy-${name}-`)); + writeFileSync(join(dir, "package.json"), "{}\n"); + writeFileSync( + join(dir, "main.js"), + 'const { value } = require("./sloppy.js");\nconsole.log(value());\n', + ); + writeFileSync( + join(dir, "sloppy.js"), + [ + 'require("fs").writeFileSync(require("path").join(__dirname, "evaluated.txt"), "evaluated\\n");', + "function value() {", + " with ({ n: 1 }) {", + " return n + 1;", + " }", + "}", + "module.exports = { value };", + "", + ].join("\n"), + ); + return dir; +} + +// A program module that runs in an island never reaches the deferral +// decision, whose probe evaluates the modules it decides: lowering loads it +// eagerly. SCRIPTC_NODE_ISLANDS_TRACE=defer prints every decision; IR +// output needs no libnode pack. +test("a program module that runs in an island is never a deferral candidate", async () => { + const dir = sloppyModuleProject("defer"); + const previous = process.env["SCRIPTC_NODE_ISLANDS_TRACE"]; + process.env["SCRIPTC_NODE_ISLANDS_TRACE"] = "defer"; + const written: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + try { + const outPath = join(dir, "main.ir.json"); + const result = await compile(join(dir, "main.js"), { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(written.filter((line) => line.includes("sloppy.js"))).toEqual([]); + const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); + expect(ir).toContain("nodeIsland.require"); + expect(ir).not.toContain("nodeIsland.defer"); + expect(existsSync(join(dir, "evaluated.txt"))).toBe(false); + } finally { + spy.mockRestore(); + if (previous === undefined) delete process.env["SCRIPTC_NODE_ISLANDS_TRACE"]; + else process.env["SCRIPTC_NODE_ISLANDS_TRACE"] = previous; + rmSync(dir, { recursive: true, force: true }); + } +}, 300_000); + // Lowering checks every argument and result type for JSON data; IR output // needs no libnode pack. test("recursive JSON types cross into Node", async () => { From 932a14673cc6f23c88971399694e86f8c5620c1c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 00:06:16 -0500 Subject: [PATCH 26/44] Refuse unresolved island data paths instead of declaring extra files Removes scriptc.nodeIslands.files. A program island module ships only the data files it names by their exact path, now also a literal appended to __dirname ("/data.json"). When a module names a file by a path where there is none while a file of that name is in its directory, the build fails with SC8006 naming the path to use, instead of building an island that cannot read the file. --- .../compiler/src/diagnostics/diagnostic.ts | 12 ++ .../frontend/lowering/lower-node-island.ts | 11 +- .../compiler/src/frontend/lowering/lowerer.ts | 24 +-- .../compiler/src/node-islands/modules.test.ts | 41 ++--- packages/compiler/src/node-islands/modules.ts | 143 ++++++++++-------- packages/compiler/src/project-config.test.ts | 11 -- packages/compiler/src/project-config.ts | 24 --- 7 files changed, 116 insertions(+), 150 deletions(-) diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index f03a30874..750b1e948 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -234,6 +234,18 @@ export function nodeIslandModuleDiag(message: string, loc: SrcLoc): ScrDiagnosti }; } +/** SC8006 — a program module that runs in a Node island names a data file + * by a path where there is none while a file of that name is in its + * directory: the build ships data files by their exact path only. */ +export function nodeIslandDataFileDiag(message: string, loc: SrcLoc): ScrDiagnostic { + return { + code: "SC8006", + message, + loc, + hint: "name the data file by its path relative to the module that reads it; the build ships a Node island module's data files by their exact path", + }; +} + /* ── SC6xxx: divergences from Node (warnings) ─────────────────────────── */ /** SC6001 — a value passed to (or declared as) a narrower record type is diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index b2d0d99e5..250b5d530 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -43,6 +43,7 @@ import { resolveBareModule } from "../resolve.js"; import { nativeSubclassOfIslandDiag, nodeIslandBoundaryDiag, + nodeIslandDataFileDiag, nodeIslandModuleDiag, } from "../../diagnostics/diagnostic.js"; import { @@ -140,7 +141,10 @@ export function lowerNodeApiCall( const caller = callerPath(call); const problem = lowerer.nodeIslandModules.add(caller, specifierNode.text); if (problem !== null) { - lowerer.pushDiag(nodeIslandModuleDiag(`${surface}: ${problem}`, locOf(specifierNode))); + const diag = lowerer.nodeIslandModules.isDataFileProblem(problem) + ? nodeIslandDataFileDiag + : nodeIslandModuleDiag; + lowerer.pushDiag(diag(`${surface}: ${problem}`, locOf(specifierNode))); throw new PoisonError(); } const specifier = lowerer.lowerExprExpecting(specifierNode, STRING); @@ -373,7 +377,10 @@ function islandLoadPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr { } const problem = lowerer.nodeIslandModules.addLoaded(real); if (problem !== null) { - lowerer.pushDiag(nodeIslandModuleDiag(problem, loc)); + const diag = lowerer.nodeIslandModules.isDataFileProblem(problem) + ? nodeIslandDataFileDiag + : nodeIslandModuleDiag; + lowerer.pushDiag(diag(problem, loc)); throw new PoisonError(); } lowerer.nodeIslandLoads.push({ literal, path: real }); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cedb12e6c..ef9d5c31c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -293,7 +293,6 @@ import { intersectionTypeDiag, noLoweringDiag, overloadedSignatureTypeDiag, - projectConfigDiag, recordShapeMismatchDiag, stdlibSnapshotValueUseDiag, unsupportedNativeApiDiag, @@ -787,8 +786,7 @@ import { maybePromiseUnionOf, promiseArmedUnionOf, } from "./maybe-promise.js"; -import { NodeIslandModuleCollector, declaredIslandFiles } from "../../node-islands/modules.js"; -import { readProjectConfig } from "../../project-config.js"; +import { NodeIslandModuleCollector } from "../../node-islands/modules.js"; /** Entry function name. '%' cannot appear in a TS identifier, so a user * function can never collide with it (mangling is injective per prefix). */ @@ -9852,7 +9850,6 @@ export class Lowerer { refuseX509DynValues(this, functions); markInferredFactChecks(functions, this.program); const sloppyDynWrites = sloppyDynamicWrites(this, functions); - if (this.usesNodeIsland) this.addDeclaredIslandFiles(); const module: IrModule | null = this.diags.length > 0 ? null @@ -11148,25 +11145,6 @@ export class Lowerer { this.diags.push(d); } - /** `scriptc.nodeIslands.files`: files the program's island modules read - * without naming their path ship with those modules. A declared path - * without a file is SC8004. */ - private addDeclaredIslandFiles(): void { - if (this.nodeIslands === null || this.nodeIslands.modules.size === 0) return; - const declared = declaredIslandFiles(readProjectConfig(this.entry.fileName)); - const loc: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 }; - for (const path of declared.missing) - this.pushDiag( - projectConfigDiag( - "scriptc.nodeIslands.files", - `names ${path}, which does not exist`, - "remove the entry or create the file", - loc, - ), - ); - for (const file of declared.files) this.nodeIslandModules.addDeclaredFile(file); - } - /** The module's Node island record: the collected module tree, with each * call's caller literal and each load's path literal set to its path in * that tree. The executable build fills in the libnode pack (index.ts). */ diff --git a/packages/compiler/src/node-islands/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index 499a59e78..45769cdbe 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -2,10 +2,8 @@ import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, test } from "vitest"; -import { readProjectConfig } from "../project-config.js"; import { NodeIslandModuleCollector, - declaredIslandFiles, namedDataPaths, nodeIslandManifest, readNodeIslandModules, @@ -31,7 +29,7 @@ describe("data paths a module names", () => { // "comment.txt" const d = \`\${dir}/template.txt\`; `); - expect([...names].sort()).toEqual([ + expect([...names.keys()].sort()).toEqual([ "assets/config.json", "fallback", "fallback/payload.txt", @@ -336,35 +334,20 @@ describe("the collected module tree", () => { expect(paths.filter((path) => !path.endsWith(".cjs"))).toEqual(["assets/config.json"]); }); - test("files scriptc.nodeIslands.files declares ship with the island modules", () => { + test("a named file that is only elsewhere in the module's directory is a build error", () => { const root = project({ - "package.json": JSON.stringify({ - scriptc: { nodeIslands: { files: ["data/*.json", "docs/readme.txt", "absent.bin"] } }, - }), - "main.ts": "", - "data/a.json": "{}", - "data/b.json": "{}", - "data/c.txt": "", - "data/nested/d.json": "{}", - "docs/readme.txt": "", - "node_modules/x/data/e.json": "{}", + "src/main.ts": "", + "src/loader.cjs": + 'const fs = require("node:fs");\nfs.readFileSync(__dirname + "/config.json");\n', + "src/private/config.json": '{ "token": "secret" }', }); - const declared = declaredIslandFiles(readProjectConfig(join(root, "main.ts"))); - expect(declared.files).toEqual([ - join(root, "data/a.json"), - join(root, "data/b.json"), - join(root, "docs/readme.txt"), - ]); - expect(declared.missing).toEqual(["absent.bin"]); const collector = new NodeIslandModuleCollector(); - expect(collector.add(join(root, "main.ts"), "./data/a.json")).toBeNull(); - for (const file of declared.files) collector.addDeclaredFile(file); - expect(collector.tree()!.files.map((file) => file.path)).toEqual([ - "data/a.json", - "data/b.json", - "docs/readme.txt", - "package.json", - ]); + const problem = collector.add(join(root, "src/main.ts"), "./loader.cjs"); + expect(problem).toBe( + `${join(root, "src/loader.cjs")}:2 names the file 'config.json', which is not at that path beside the module; the build ships a module's data files by their exact path, so 'private/config.json' does not ship with the Node island. Name the file by its path relative to the module ('private/config.json')`, + ); + expect(collector.isDataFileProblem(problem!)).toBe(true); + expect(collector.tree()!.files.map((file) => file.path)).not.toContain("private/config.json"); }); test("the manifest names relative paths only, and the data is checked against it", () => { diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index ae97de055..4f7569de8 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -40,9 +40,7 @@ import { trackedRealpath, } from "../frontend/input-tracker.js"; import type { IrNodeIslandModules } from "../ir/ir.js"; -import type { ProjectConfig } from "../project-config.js"; import { packageDirOf } from "./origin.js"; -import { globMatch } from "./policy.js"; export type NodeIslandModuleTree = IrNodeIslandModules; type NodeIslandModuleFile = IrNodeIslandModules["files"][number]; @@ -178,6 +176,14 @@ export class NodeIslandModuleCollector { private readonly loaded = new Set(); /** Program modules whose source the program provides (addProvided). */ private readonly provided = new Set(); + /** The problems addNamedData reported (isDataFileProblem). */ + private readonly dataProblems = new Set(); + + /** Whether `problem`, returned by add or addLoaded, is a data file the + * build does not ship (addNamedData) rather than a module. */ + isDataFileProblem(problem: string): boolean { + return this.dataProblems.has(problem); + } /** Collects the module `specifier` names from `caller` (a real path). * Returns null, or the reason the build cannot ship it. */ @@ -326,7 +332,8 @@ export class NodeIslandModuleCollector { if (!TRACED.test(file)) return null; const text = trackedReadFile(file); if (text === null) return `cannot read ${file}`; - this.addNamedData(file, text); + const dataProblem = this.addNamedData(file, text); + if (dataProblem !== null) return dataProblem; const scan = scanModuleSpecifiers(text); if (scan.computed.length > 0) { const first = scan.computed[0]!; @@ -345,37 +352,70 @@ export class NodeIslandModuleCollector { * read to the wasm next to the patching module), and the island runs from * the shipped tree, so a file the module names by its path relative to * the module's directory ships with it (namedDataPaths). Only the exact - * path ships: a file elsewhere in the tree with the same name stays out, - * and so does every file the source does not name; such files ship when - * `scriptc.nodeIslands.files` declares them (addDeclaredFile). Module - * sources ship through the traced walk, never as data. A named path - * stays inside the module's directory: one that leads outside it, - * through `..` or a symbolic link, or into node_modules or .git, is not - * shipped, so nothing from elsewhere on the machine is embedded. */ - private addNamedData(moduleFile: string, text: string): void { + * path ships, and module sources ship through the traced walk, never as + * data. A named path stays inside the module's directory: one that leads + * outside it, through `..` or a symbolic link, or into node_modules or + * .git, is not shipped, so nothing from elsewhere on the machine is + * embedded. + * + * Returns the reason the build cannot ship the module when it names a + * file by a path where there is none while a file of that name is inside + * its directory: the island would read a file the build left out. */ + private addNamedData(moduleFile: string, text: string): string | null { const root = dirname(moduleFile); const inside = (path: string): boolean => { const rel = relative(root, path); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); }; - for (const name of namedDataPaths(text)) { + const unresolved: { name: string; at: number }[] = []; + for (const [name, at] of namedDataPaths(text)) { if (isAbsolute(name) || name.includes("\0")) continue; const path = resolve(root, name); if (!inside(path) || path === moduleFile || TRACED.test(path)) continue; const parts = relative(root, path).split(sep); if (parts.includes("node_modules") || parts.includes(".git")) continue; - if (!trackedFileExists(path)) continue; + if (!trackedFileExists(path)) { + if (basename(name).includes(".")) unresolved.push({ name, at }); + continue; + } const target = trackedRealpath(path); if (target === null || !inside(target)) continue; // A link inside the directory stays a link in the tree (placed). this.files.add(this.placed(path)); } - } - - /** A file `scriptc.nodeIslands.files` declares for the island modules of - * the program (declaredIslandFiles): shipped at its path in the tree. */ - addDeclaredFile(file: string): void { - this.files.add(this.placed(file)); + if (unresolved.length === 0) return null; + // The files of the module's directory by name, links leading outside it + // not followed. + const byName = new Map(); + const visited = new Set(); + const walk = (dir: string): void => { + const real = trackedRealpath(dir); + if (real === null || (real !== root && !inside(real)) || visited.has(real)) return; + visited.add(real); + const entries = trackedAccessibleEntries(dir); + if (entries === null) return; + for (const file of entries.files) { + const path = join(dir, file); + if (byName.has(file) || TRACED.test(file) || path === moduleFile) continue; + const target = trackedRealpath(path); + if (target !== null && inside(target)) byName.set(file, path); + } + for (const sub of entries.directories) + if (sub !== "node_modules" && sub !== ".git") walk(join(dir, sub)); + }; + walk(root); + for (const { name, at } of unresolved) { + const found = byName.get(basename(name)); + // A file another named path ships (`"payload.txt"` beside + // `join(here, "fallback", "payload.txt")`) is not missing. + if (found === undefined || this.files.has(trackedRealpath(found) ?? found)) continue; + const line = text.slice(0, at).split("\n").length; + const there = portable(relative(root, found)); + const problem = `${moduleFile}:${line} names the file '${name}', which is not at that path beside the module; the build ships a module's data files by their exact path, so '${there}' does not ship with the Node island. Name the file by its path relative to the module ('${there}')`; + this.dataProblems.add(problem); + return problem; + } + return null; } /** A package: its whole directory, then its dependencies. */ @@ -631,51 +671,24 @@ export interface ModuleSpecifierScan { computed: { at: number; form: string }[]; } -/** The files `scriptc.nodeIslands.files` declares: each path, relative to - * the package.json, and the files under the package.json's directory each - * glob matches (node_modules and .git directories excluded). `missing` - * lists the paths without a file. */ -export function declaredIslandFiles(config: ProjectConfig): { files: string[]; missing: string[] } { - const patterns = config.nodeIslands.files; - if (patterns.length === 0) return { files: [], missing: [] }; - const base = config.manifest === null ? process.cwd() : dirname(config.manifest); - const files = new Set(); - const missing: string[] = []; - const globs: string[] = []; - for (const pattern of patterns) { - const path = resolve(base, pattern); - if (!/[*?]/.test(pattern)) { - if (trackedFileExists(path)) files.add(path); - else missing.push(pattern); - } else globs.push(portable(path)); - } - if (globs.length > 0) { - const walk = (dir: string): void => { - const entries = trackedAccessibleEntries(dir); - if (entries === null) return; - for (const file of entries.files) { - const path = join(dir, file); - if (globs.some((glob) => globMatch(glob, portable(path)))) files.add(path); - } - for (const sub of entries.directories) - if (sub !== "node_modules" && sub !== ".git") walk(join(dir, sub)); - }; - walk(base); - } - return { files: [...files].sort(), missing }; -} - -/** The relative paths a module's source names by string literals: each +/** The relative paths a module's source names by string literals, with the + * offset of the first literal that names each: each * literal (`"data/table.json"`), and the literals passed as consecutive * arguments of one call joined with `/`, as `path.join(here, "fallback", * "payload.txt")` names `fallback/payload.txt`. Comments and template - * literals with substitutions name nothing. */ -export function namedDataPaths(text: string): Set { + * literals with substitutions name nothing; `__dirname + "/data.json"` names + * `data.json`. */ +export function namedDataPaths(text: string): Map { const tokens = moduleTokens(text); - const names = new Set(); - let run: string[] = []; + const names = new Map(); + const add = (name: string, at: number): void => { + if (!names.has(name)) names.set(name, at); + }; + /** The open brackets around the current token. */ + const open: string[] = []; + let run: Token[] = []; const end = (): void => { - if (run.length > 1) names.add(run.join("/")); + if (run.length > 1) add(run.map((t) => t.value).join("/"), run[0]!.at); run = []; }; for (let k = 0; k < tokens.length; k++) { @@ -685,18 +698,26 @@ export function namedDataPaths(text: string): Set { end(); continue; } - names.add(t.value); + add(t.value, t.at); const before = tokens[k - 1]; const after = tokens[k + 1]; + // `__dirname + "/data.json"` names data.json beside the module. + if (before?.kind === "punct" && before.value === "+" && t.value.startsWith("/")) + add(t.value.slice(1), t.at); const argument = + open[open.length - 1] === "(" && before?.kind === "punct" && (before.value === "(" || before.value === ",") && after?.kind === "punct" && (after.value === ")" || after.value === ","); if (!argument) end(); - else run.push(t.value); + else run.push(t); continue; } + if (t.kind === "punct") { + if (t.value === "(" || t.value === "[" || t.value === "{") open.push(t.value); + else if (t.value === ")" || t.value === "]" || t.value === "}") open.pop(); + } if (t.kind !== "punct" || t.value !== ",") end(); } end(); diff --git a/packages/compiler/src/project-config.test.ts b/packages/compiler/src/project-config.test.ts index 7282390ac..ddf93d79d 100644 --- a/packages/compiler/src/project-config.test.ts +++ b/packages/compiler/src/project-config.test.ts @@ -17,7 +17,6 @@ test("a package.json without a scriptc key has the default configuration", () => expect(c.nodeIslands).toEqual({ auto: true, modules: [], - files: [], node: null, nodeVersion: null, packaging: "embedded", @@ -104,7 +103,6 @@ test("nodeIslands settings", () => { nodeIslands: { auto: false, modules: ["src/plugins/*.ts"], - files: ["data/*.json"], node: "/usr/local/bin/node", nodeVersion: ">=24.13.1", packaging: "beside", @@ -117,7 +115,6 @@ test("nodeIslands settings", () => { expect(c.nodeIslands).toEqual({ auto: false, modules: ["src/plugins/*.ts"], - files: ["data/*.json"], node: "/usr/local/bin/node", nodeVersion: ">=24.13.1", packaging: "beside", @@ -136,14 +133,6 @@ test("publicEnv must be an array of variable names", () => { expect(c.nodeIslands.publicEnv).toEqual([]); }); -test("files must be an array of paths or globs", () => { - const c = config({ nodeIslands: { files: "data/config.json" } }); - expect(c.diagnostics.map((d) => d.message)).toEqual([ - `package.json 'scriptc.nodeIslands.files' must be an array of file paths or globs, not "data/config.json"`, - ]); - expect(c.nodeIslands.files).toEqual([]); -}); - test("unknown keys and values are SC8004, located at the key", () => { const text = JSON.stringify( { diff --git a/packages/compiler/src/project-config.ts b/packages/compiler/src/project-config.ts index 653b9678d..9b52993db 100644 --- a/packages/compiler/src/project-config.ts +++ b/packages/compiler/src/project-config.ts @@ -14,8 +14,6 @@ * "zod": { "shapes": false } // no shape-inference overlay * }, * "nodeIslands": { "auto": true, "modules": ["src/plugins/*.ts"] }, - * // files island modules of the program read, shipped with them - * // (node-islands/modules.ts): "nodeIslands": { "files": ["data/*.json"] } * "wasm": ["build/module.wasm"], * "typePatches": { // frontend/type-patches/layer.ts * "commander@^15.0.0": { @@ -62,10 +60,6 @@ export interface NodeIslandsSetting { /** User modules (paths or globs relative to the package.json) that run * in Node. */ modules: string[]; - /** Files (paths or globs relative to the package.json) that ship with - * the island modules of the program, beside the files their source names - * by path (node-islands/modules.ts). */ - files: string[]; /** The `node` executable recorded into the binary. */ node: string | null; /** A semver range checked at the helper handshake. */ @@ -137,7 +131,6 @@ const PACKAGE_SETTING_KEYS = [...Object.keys(PACKAGE_OBJECT_KEYS), "shapes"]; const NODE_ISLANDS_KEYS = [ "auto", "modules", - "files", "node", "nodeVersion", "packaging", @@ -161,7 +154,6 @@ function defaultNodeIslands(): NodeIslandsSetting { return { auto: true, modules: [], - files: [], node: null, nodeVersion: null, packaging: "embedded", @@ -352,22 +344,6 @@ export function parseProjectConfig(manifest: string, text: string): ProjectConfi `write "modules": ["src/plugins/loader.ts"]`, ); } else setting.modules = list; - } else if (key === "files") { - const list: string[] = []; - let ok = Array.isArray(v); - if (Array.isArray(v)) { - for (const entry of v as unknown[]) { - if (typeof entry === "string" && entry !== "") list.push(entry); - else ok = false; - } - } - if (!ok) { - problem( - ["nodeIslands", key], - `must be an array of file paths or globs, not ${shown(v)}`, - `write "files": ["data/config.json"]`, - ); - } else setting.files = list; } else if (key === "publicEnv") { const list: string[] = []; let ok = Array.isArray(v); From 6874c3b5626fa70826c141a508acffc4735d0d8f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 00:18:39 -0500 Subject: [PATCH 27/44] Ship a joined island data path when another directory has its name A module that names fallback/payload.txt through join(here, "fallback", "payload.txt") also names "payload.txt" alone. That name was checked against the first file of that name the directory walk found, so a payload.txt in another subdirectory made the build fail with SC8006 even though the joined path ships. A name whose file a named path of the same module ships is no longer reported. --- .../compiler/src/node-islands/modules.test.ts | 15 +++++++++++++++ packages/compiler/src/node-islands/modules.ts | 9 +++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/node-islands/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index 45769cdbe..381fac346 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -334,6 +334,21 @@ describe("the collected module tree", () => { expect(paths.filter((path) => !path.endsWith(".cjs"))).toEqual(["assets/config.json"]); }); + test("a joined data path ships even when another directory has a file of that name", () => { + const root = project({ + "src/main.ts": "", + "src/loader.cjs": + 'const { join } = require("node:path");\nrequire("node:fs").readFileSync(join(__dirname, "fallback", "payload.txt"));\n', + "src/a-other/payload.txt": "not named by the module\n", + "src/fallback/payload.txt": "shipped\n", + }); + const collector = new NodeIslandModuleCollector(); + expect(collector.add(join(root, "src/main.ts"), "./loader.cjs")).toBeNull(); + const paths = collector.tree()!.files.map((file) => file.path); + expect(paths).toContain("fallback/payload.txt"); + expect(paths).not.toContain("a-other/payload.txt"); + }); + test("a named file that is only elsewhere in the module's directory is a build error", () => { const root = project({ "src/main.ts": "", diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index 4f7569de8..1e3e43ab5 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -368,6 +368,8 @@ export class NodeIslandModuleCollector { return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); }; const unresolved: { name: string; at: number }[] = []; + /** The names of the files this module's named paths ship. */ + const shippedNames = new Set(); for (const [name, at] of namedDataPaths(text)) { if (isAbsolute(name) || name.includes("\0")) continue; const path = resolve(root, name); @@ -382,6 +384,7 @@ export class NodeIslandModuleCollector { if (target === null || !inside(target)) continue; // A link inside the directory stays a link in the tree (placed). this.files.add(this.placed(path)); + shippedNames.add(basename(path)); } if (unresolved.length === 0) return null; // The files of the module's directory by name, links leading outside it @@ -405,9 +408,11 @@ export class NodeIslandModuleCollector { }; walk(root); for (const { name, at } of unresolved) { + // A name of a file another named path ships (`"payload.txt"` beside + // `join(here, "fallback", "payload.txt")`) is not missing, wherever + // other files of that name are. + if (shippedNames.has(basename(name))) continue; const found = byName.get(basename(name)); - // A file another named path ships (`"payload.txt"` beside - // `join(here, "fallback", "payload.txt")`) is not missing. if (found === undefined || this.files.has(trackedRealpath(found) ?? found)) continue; const line = text.slice(0, at).split("\n").length; const there = portable(relative(root, found)); From 015cc47ff29ab88b7a648aee4fb2b34bc8a9ff98 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 00:46:57 -0500 Subject: [PATCH 28/44] Capture island locals in closures, load islands in import order, and narrow SC1010 Three fixes. A function-local island handle read inside a closure, such as an island array spread in an arrow (`() => [...local]`), resolved to the enclosing function's binding without registering a capture, so the closure referred to a local it does not have (SC9001). The handle is now read through resolveLocal after its binding is retyped checked-dynamic, which captures it like any other local. A static island import or re-export loaded the island module in the importer's %init prelude, after every native dependency the file imports. A native module imported after the island package therefore evaluated first, which reverses Node's dependency order. The load now runs in its statement's place among the dependencies' %init calls. An island statement the import order leaves out keeps the prelude's place. Tests: 6897-node-island-declared-arrays gains closures that read, measure and spread a function-local island array. node-island-import-order checks evaluation order when an island re-export precedes a native import, and when an island import sits between two native imports. The SC1010 refusal of an import or re-export that resolves to type declarations now applies only when the declaration has no runtime file beside it (x.js for x.d.ts, x.mjs for x.d.mts, x.cjs for x.d.cts). A relative import of `./node_modules/dependency/index.js` names a module Node loads, and keeps its previous SC2013. The noUnusedLocals test reaches its declaration file through a type-only re-export, since a runtime import of a declaration-only file is now refused, as Node fails to load it. --- .../src/frontend/lowering/lower-modules.ts | 14 ++++++- .../frontend/lowering/lower-node-island.ts | 40 +++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 7 ++++ packages/compiler/src/frontend/program.ts | 21 +++++++++- .../test/lib-diagnostics-options.test.ts | 2 +- .../node-island-import-order/main.ts.json | 10 +++++ .../6897-node-island-declared-arrays/main.ts | 10 +++++ .../corpus/node-island-import-order/barrel.ts | 5 +++ tests/corpus/node-island-import-order/main.ts | 8 ++++ .../node-island-import-order/native-a.ts | 2 + .../node-island-import-order/native-b.ts | 2 + .../node_modules/laterkit/index.d.ts | 1 + .../node_modules/laterkit/index.js | 4 ++ .../node_modules/laterkit/package.json | 1 + .../node_modules/sidekit/index.d.ts | 1 + .../node_modules/sidekit/index.js | 4 ++ .../node_modules/sidekit/package.json | 1 + .../node-island-import-order/package.json | 1 + tests/corpus/node-island-import-order/user.ts | 5 +++ 19 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json create mode 100644 tests/corpus/node-island-import-order/barrel.ts create mode 100644 tests/corpus/node-island-import-order/main.ts create mode 100644 tests/corpus/node-island-import-order/native-a.ts create mode 100644 tests/corpus/node-island-import-order/native-b.ts create mode 100644 tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts create mode 100644 tests/corpus/node-island-import-order/node_modules/laterkit/index.js create mode 100644 tests/corpus/node-island-import-order/node_modules/laterkit/package.json create mode 100644 tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts create mode 100644 tests/corpus/node-island-import-order/node_modules/sidekit/index.js create mode 100644 tests/corpus/node-island-import-order/node_modules/sidekit/package.json create mode 100644 tests/corpus/node-island-import-order/package.json create mode 100644 tests/corpus/node-island-import-order/user.ts diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 82a1884bd..aefcbc0af 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -3225,7 +3225,14 @@ export function lowerFileInit( }); ctx.hoistedFnDecls.add(stmt); } + const islandActions = lowerer.islandImportActions.get(sf); + const islandPlaced = new Set(); for (const { stmt, dep } of orderedImportsOf(lowerer.program, sf)) { + const island = islandActions?.get(stmt); + if (island !== undefined) { + header.push(...island); + islandPlaced.add(stmt); + } const npm = lowerer.npmInitActions.get(sf)?.get(stmt); if (npm !== undefined && npm.length > 0) { header.push(...npm); @@ -3294,6 +3301,11 @@ export function lowerFileInit( } } } + // An island load whose statement the import order leaves out keeps the + // prelude's place. + const islandUnplaced: IrStmt[] = []; + for (const [stmt, actions] of islandActions ?? []) + if (!islandPlaced.has(stmt)) islandUnplaced.push(...actions); if (asyncDeps.length === 1) { const dep = asyncDeps[0]!; const promiseT: IrType = { kind: "promise", inner: VOID }; @@ -3432,7 +3444,7 @@ export function lowerFileInit( if (ms && ms.sf === sf) statics.push({ pos: ms.pos, info: c }); } statics.sort((a, b) => a.pos - b.pos); - const body = [...header, ...prelude]; + const body = [...header, ...islandUnplaced, ...prelude]; let at = 0; for (const stmt of stmts) { // `<=`: a mixin instantiation's position IS its statement's start diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index c36dc345f..df515a3d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -31,6 +31,7 @@ import { VOID, type IrExpr, type IrGlobal, + type IrLocal, type IrStmt, type IrType, type SrcLoc, @@ -390,9 +391,26 @@ function runsAtModuleEvaluation(node: ts.Node): boolean { return false; } +/** The %init statements that run in `stmt`'s place in its file's %init + * header (Lowerer.islandImportActions). */ +function islandStatementActions(lowerer: Lowerer, sf: ts.SourceFile, stmt: ts.Statement): IrStmt[] { + let byStatement = lowerer.islandImportActions.get(sf); + if (byStatement === undefined) { + byStatement = new Map(); + lowerer.islandImportActions.set(sf, byStatement); + } + let actions = byStatement.get(stmt); + if (actions === undefined) { + actions = []; + byStatement.set(stmt, actions); + } + return actions; +} + /** A static import (or re-export) of an island package: loads the module - * in the importer's %init prelude. Returns false when `spec` is not an - * island module. */ + * in the importer's %init header, in the statement's place among the + * dependencies' %init calls, as Node evaluates it in source order. Returns + * false when `spec` is not an island module. */ export function collectIslandImport( lowerer: Lowerer, sf: ts.SourceFile, @@ -418,8 +436,7 @@ export function collectIslandImport( lowerer.usesNodeIsland = true; const exportLoc = locOf(stmt); const exported = islandModuleGlobal(lowerer, path); - const initActions = lowerer.jsonInitActions.get(sf) ?? []; - lowerer.jsonInitActions.set(sf, initActions); + const initActions = islandStatementActions(lowerer, sf, stmt); initActions.push({ kind: "assign", localId: exported.global.id, @@ -446,8 +463,7 @@ export function collectIslandImport( lowerer.usesNodeIsland = true; const loc = locOf(stmt); const binding = islandModuleGlobal(lowerer, path); - const actions = lowerer.jsonInitActions.get(sf) ?? []; - lowerer.jsonInitActions.set(sf, actions); + const actions = islandStatementActions(lowerer, sf, stmt); actions.push({ kind: "assign", localId: binding.global.id, @@ -1499,6 +1515,16 @@ function lowerIslandChain( return stmts.length > 0 ? { kind: "seqExpr", stmts, result, type: result.type, loc } : result; } +/** The local an island handle binding reads here: its own binding, retyped + * checked-dynamic, or, inside a closure, the capture of an enclosing + * function's binding, which resolveLocal registers. */ +function islandHandleLocal(lowerer: Lowerer, ident: ts.Identifier): IrLocal | null { + const origin = lowerer.peekLocal(ident); + if (origin === null) return null; + origin.type = DYN; + return lowerer.resolveLocal(ident); +} + /** Expressions on island values (the lowerExpr hook; null: not an island * expression, the ordinary lowering applies). */ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | null { @@ -1527,7 +1553,7 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | expr, ); lowerer.usesNodeIsland = true; - const slot = lowerer.globalOf(expr) ?? lowerer.peekLocal(expr); + const slot = lowerer.globalOf(expr) ?? islandHandleLocal(lowerer, expr); if (slot === null) return null; // the local is declared dyn slot.type = DYN; const ref = varRef(slot.id, DYN, loc); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 43822d695..ac74ee69f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3662,6 +3662,12 @@ export class Lowerer { readonly globalsList: IrGlobal[] = []; readonly npmInitActions = new Map>(); + /** Per-file %init statements of static island imports and re-exports, + * by the import or export statement that loads the island module: the + * importer's %init header runs them in that statement's place among its + * dependencies' %init calls, so the island module evaluates in source + * order with the native dependencies around it. */ + readonly islandImportActions = new Map>(); /** Per-file %init PRELUDE statements for JSON imports: bakeable DATA * assignments with no observable evaluation order of their own — * prepended by lowerFileInit so the bindings are live before any @@ -4380,6 +4386,7 @@ export class Lowerer { checkedTable("globalsByDeclNode", () => this.globalsByDeclNode.size), checkedTable("globalsList", () => this.globalsList.length), checkedTable("npmInitActions", () => this.npmInitActions.size), + checkedTable("islandImportActions", () => this.islandImportActions.size), checkedTable("jsonInitActions", () => this.jsonInitActions.size), checkedTable("jsonRequireModules", () => this.jsonRequireModules.size), checkedTable("jsonRequireCalls", () => this.jsonRequireCalls.size), diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 1b4eb3579..b0fbb0189 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5532,6 +5532,23 @@ export function checkPreflightTs7( } } +/** Whether a declaration file has the runtime file TypeScript pairs it + * with (`x.d.ts` with `x.js`, `x.d.mts` with `x.mjs`, `x.d.cts` with + * `x.cjs`): a specifier that resolves to the declaration then still names a + * module Node can load. */ +function declarationHasRuntimeFile(declaration: ts.SourceFile): boolean { + const name = declaration.fileName; + const pairs: [string, string][] = [ + [".d.ts", ".js"], + [".d.mts", ".mjs"], + [".d.cts", ".cjs"], + ]; + for (const [declared, runtime] of pairs) + if (name.endsWith(declared)) + return trackedFileExists(name.slice(0, -declared.length) + runtime); + return false; +} + function preflight7(load: LoadResult): { diags: ScrDiagnostic[]; moduleOrder: ts.SourceFile[]; @@ -6065,7 +6082,7 @@ function preflight7(load: LoadResult): { // ERR_MODULE_NOT_FOUND), and a declaration file is not a program // module: an edge to it would lower the declarations' own imports // as code. - if (reDep !== null && reDep.isDeclarationFile) { + if (reDep !== null && reDep.isDeclarationFile && !declarationHasRuntimeFile(reDep)) { diags.push( unsupportedDiag( "SC1010", @@ -6271,7 +6288,7 @@ function preflight7(load: LoadResult): { // edge would lower its imports as if it were code (SC2013 for every // specifier inside a package's `.d.ts`). Type-only imports were // already erased above; this is the value form. - if (dep !== null && dep.isDeclarationFile && !isJson) { + if (dep !== null && dep.isDeclarationFile && !isJson && !declarationHasRuntimeFile(dep)) { diags.push( unsupportedDiag( "SC1010", diff --git a/packages/compiler/test/lib-diagnostics-options.test.ts b/packages/compiler/test/lib-diagnostics-options.test.ts index 1ef69d867..c561f1c2f 100644 --- a/packages/compiler/test/lib-diagnostics-options.test.ts +++ b/packages/compiler/test/lib-diagnostics-options.test.ts @@ -88,7 +88,7 @@ test("noUnusedLocals and noUnusedParameters report nothing in declaration files" const codes = (extension: string, text: string): string[] => checkPreflightTs7( project(options, { - "main.ts": 'import "./extra.js";\nexport {};\n', + "main.ts": 'export type {} from "./extra.js";\n', [`extra.${extension}`]: text, }), ).diags.map((d) => d.message); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json new file mode 100644 index 000000000..a6aa6d7e8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/node-island-import-order/main.ts.json @@ -0,0 +1,10 @@ +{ + "order": [ + "/tests/corpus/node-island-import-order/native-a.ts", + "/tests/corpus/node-island-import-order/barrel.ts", + "/tests/corpus/node-island-import-order/native-b.ts", + "/tests/corpus/node-island-import-order/user.ts", + "/tests/corpus/node-island-import-order/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6897-node-island-declared-arrays/main.ts b/tests/corpus/6897-node-island-declared-arrays/main.ts index a5c02a23d..9093d97de 100644 --- a/tests/corpus/6897-node-island-declared-arrays/main.ts +++ b/tests/corpus/6897-node-island-declared-arrays/main.ts @@ -38,3 +38,13 @@ const second = list(); const sortedNative: string[] = [...first]; sortedNative.sort(); console.log(sortedNative.join(","), second.join(","), describe(first)); + +// A function-local island array captured by an arrow crosses the same way. +function captured(): string { + const local = list(); + const show = (): string => describe(local); + const size = (): number => local.length; + const spread = (): string => [...local].join(","); + return `${show()} ${size()} ${spread()}`; +} +console.log(captured()); diff --git a/tests/corpus/node-island-import-order/barrel.ts b/tests/corpus/node-island-import-order/barrel.ts new file mode 100644 index 000000000..525d358c0 --- /dev/null +++ b/tests/corpus/node-island-import-order/barrel.ts @@ -0,0 +1,5 @@ +// The island re-export comes first, so the island module evaluates before +// the native module this barrel imports next. +export { tag } from "sidekit"; +import { a } from "./native-a.ts"; +console.log("barrel evaluates", a); diff --git a/tests/corpus/node-island-import-order/main.ts b/tests/corpus/node-island-import-order/main.ts new file mode 100644 index 000000000..92f18fad9 --- /dev/null +++ b/tests/corpus/node-island-import-order/main.ts @@ -0,0 +1,8 @@ +// @requires-libnode +// Static imports and re-exports of a Node island package evaluate the +// island module in source order among the native modules a file imports, +// as Node evaluates dependencies: an island package's top-level code runs +// before a native module imported after it, and after one imported before. +import { tag } from "./barrel.ts"; +import "./user.ts"; +console.log("main", tag("main")); diff --git a/tests/corpus/node-island-import-order/native-a.ts b/tests/corpus/node-island-import-order/native-a.ts new file mode 100644 index 000000000..41180e891 --- /dev/null +++ b/tests/corpus/node-island-import-order/native-a.ts @@ -0,0 +1,2 @@ +console.log("native module a evaluates"); +export const a = "a"; diff --git a/tests/corpus/node-island-import-order/native-b.ts b/tests/corpus/node-island-import-order/native-b.ts new file mode 100644 index 000000000..63f51a2e8 --- /dev/null +++ b/tests/corpus/node-island-import-order/native-b.ts @@ -0,0 +1,2 @@ +console.log("native module b evaluates"); +export const b = "b"; diff --git a/tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts b/tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts new file mode 100644 index 000000000..ee4242fe4 --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/laterkit/index.d.ts @@ -0,0 +1 @@ +export declare function tag(text: string): string; diff --git a/tests/corpus/node-island-import-order/node_modules/laterkit/index.js b/tests/corpus/node-island-import-order/node_modules/laterkit/index.js new file mode 100644 index 000000000..9498a667c --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/laterkit/index.js @@ -0,0 +1,4 @@ +"use strict"; +// Top-level code with an observable effect: when it runs is the test. +console.log("second island module evaluates"); +exports.tag = (text) => "<" + text + ">"; diff --git a/tests/corpus/node-island-import-order/node_modules/laterkit/package.json b/tests/corpus/node-island-import-order/node_modules/laterkit/package.json new file mode 100644 index 000000000..17c4d36a7 --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/laterkit/package.json @@ -0,0 +1 @@ +{ "name": "laterkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts b/tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts new file mode 100644 index 000000000..ee4242fe4 --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/sidekit/index.d.ts @@ -0,0 +1 @@ +export declare function tag(text: string): string; diff --git a/tests/corpus/node-island-import-order/node_modules/sidekit/index.js b/tests/corpus/node-island-import-order/node_modules/sidekit/index.js new file mode 100644 index 000000000..c0de7152d --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/sidekit/index.js @@ -0,0 +1,4 @@ +"use strict"; +// Top-level code with an observable effect: when it runs is the test. +console.log("island module evaluates"); +exports.tag = (text) => "[" + text + "]"; diff --git a/tests/corpus/node-island-import-order/node_modules/sidekit/package.json b/tests/corpus/node-island-import-order/node_modules/sidekit/package.json new file mode 100644 index 000000000..1d6608b2f --- /dev/null +++ b/tests/corpus/node-island-import-order/node_modules/sidekit/package.json @@ -0,0 +1 @@ +{ "name": "sidekit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/node-island-import-order/package.json b/tests/corpus/node-island-import-order/package.json new file mode 100644 index 000000000..c4ccd8ecc --- /dev/null +++ b/tests/corpus/node-island-import-order/package.json @@ -0,0 +1 @@ +{ "name": "node-island-import-order", "private": true, "type": "module", "scriptc": { "packages": { "sidekit": "node", "laterkit": "node" } } } diff --git a/tests/corpus/node-island-import-order/user.ts b/tests/corpus/node-island-import-order/user.ts new file mode 100644 index 000000000..afb0b0edd --- /dev/null +++ b/tests/corpus/node-island-import-order/user.ts @@ -0,0 +1,5 @@ +// A native import, then an island import, then another native import. +import { b } from "./native-b.ts"; +import { tag } from "laterkit"; +import { a } from "./native-a.ts"; +console.log("user evaluates", tag(a + b)); From e4bc919aecc814c0ab628a28ab9d5a2fccfbaf49 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 01:18:35 -0500 Subject: [PATCH 29/44] Refuse to evaluate program source in the island deferral probe The build-time probe loads island package modules to decide their deferral. A package can require a file outside node_modules (a program module it shares, or any project file), and that file evaluated on the build machine. The probe's resolve hook now throws for a file outside every node_modules directory before it evaluates, so the module that asked for it stays eager. This also covers a program module passed to the probe directly. --- .../src/node-islands/probe-recorder.test.ts | 74 +++++++++++++++++++ .../src/node-islands/probe-recorder.ts | 14 +++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/node-islands/probe-recorder.test.ts diff --git a/packages/compiler/src/node-islands/probe-recorder.test.ts b/packages/compiler/src/node-islands/probe-recorder.test.ts new file mode 100644 index 000000000..2e82be3e9 --- /dev/null +++ b/packages/compiler/src/node-islands/probe-recorder.test.ts @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { PROBE_RECORDER } from "./probe-recorder.js"; + +const made: string[] = []; +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function project(files: Record): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-probe-recorder-"))); + made.push(root); + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + return root; +} + +interface ProbeResult { + path: string; + throws?: string; + root?: number; +} + +/** Runs the recorder in a Node process of its own, as the probe host runs + * it in libnode, over `modules`. */ +function probe(root: string, modules: string[]): ProbeResult[] { + writeFileSync(join(root, "recorder.cjs"), PROBE_RECORDER); + writeFileSync( + join(root, "run.cjs"), + `const out = require("./recorder.cjs").probe({ modules: ${JSON.stringify(modules)} });\n` + + `process.stdout.write(JSON.stringify(out.results));\n`, + ); + const run = spawnSync(process.execPath, [join(root, "run.cjs")], { cwd: root, encoding: "utf8" }); + expect(run.status, run.stderr).toBe(0); + return JSON.parse(run.stdout) as ProbeResult[]; +} + +// A source file that, when it evaluates, writes `evaluated-` beside itself. +const marker = (name: string): string => + `require("fs").writeFileSync(require("path").join(__dirname, "evaluated-${name}"), "");\nmodule.exports = { n: 1 };\n`; + +test("the probe evaluates an installed package", () => { + const root = project({ + "node_modules/pkg/package.json": '{ "name": "pkg" }', + "node_modules/pkg/index.js": "module.exports = { answer: 42 };\n", + }); + const [result] = probe(root, [join(root, "node_modules/pkg/index.js")]); + expect(result!.throws).toBeUndefined(); + expect(result!.root).toBeDefined(); +}); + +test("the probe never evaluates the program's own source", () => { + const root = project({ + "src/program.js": marker("program"), + "src/shared.js": marker("shared"), + "node_modules/reaches/package.json": '{ "name": "reaches" }', + "node_modules/reaches/index.js": 'module.exports = require("../../src/shared.js");\n', + }); + const results = probe(root, [ + join(root, "src/program.js"), + join(root, "node_modules/reaches/index.js"), + ]); + expect(results.map((r) => r.throws)).toEqual([ + `the build does not evaluate ${join(root, "src/program.js")}, a file of the program`, + `the build does not evaluate ${join(root, "src/shared.js")}, a file of the program`, + ]); + expect(existsSync(join(root, "src/evaluated-program"))).toBe(false); + expect(existsSync(join(root, "src/evaluated-shared"))).toBe(false); +}); diff --git a/packages/compiler/src/node-islands/probe-recorder.ts b/packages/compiler/src/node-islands/probe-recorder.ts index 499de3593..af29be084 100644 --- a/packages/compiler/src/node-islands/probe-recorder.ts +++ b/packages/compiler/src/node-islands/probe-recorder.ts @@ -20,6 +20,8 @@ * (island-local, since native code has its own builtins, but * observable by other island modules evaluated out of order); * - every file the load required (the module's closure), and a throw; + * a load that resolves a file outside every node_modules directory (the + * program's own source) throws before that file evaluates; * * and takes the interface snapshot of the module's value (snapshot.ts). * Calls made by Node's own internals (the module loader reading files, @@ -429,11 +431,19 @@ exports.probe = function probe(input) { undo.push(() => hook.disable()); // The closure: a resolve hook (no frame of ours stays on the stack while // modules evaluate, so Node's node_modules checks see the real callers). + // The probe evaluates installed packages only: a file outside every + // node_modules directory is the program's own source (a program module, + // or a project file a package requires), and its load throws before the + // file evaluates, so the module that asked for it stays eager. const hooks = Module.registerHooks({ resolve(specifier, context, nextResolve) { const result = nextResolve(specifier, context); - if (recording && typeof result.url === "string" && result.url.startsWith("file:")) - files.add(require("node:url").fileURLToPath(result.url)); + if (recording && typeof result.url === "string" && result.url.startsWith("file:")) { + const file = require("node:url").fileURLToPath(result.url); + if (!/[\\/]node_modules[\\/]/.test(file)) + throw new Error("the build does not evaluate " + file + ", a file of the program"); + files.add(file); + } return result; }, }); From 1ec321ed77de89726e33c810b324aee0a8b4e20a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 01:30:27 -0500 Subject: [PATCH 30/44] Keep a probe load eager when it catches the refusal of a program file A package that catches the probe's refusal to load a program file evaluated a fallback, and the probe recorded that load as clean and snapshotted the fallback's exports, which the executable never has. A load in which the resolve hook refused a program file is now recorded as throwing the refusal whether or not the load caught it, and so is a later load that reaches one of its files through the module cache. --- .../src/node-islands/probe-recorder.test.ts | 22 +++++++++++++++++++ .../src/node-islands/probe-recorder.ts | 22 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/node-islands/probe-recorder.test.ts b/packages/compiler/src/node-islands/probe-recorder.test.ts index 2e82be3e9..f67522a38 100644 --- a/packages/compiler/src/node-islands/probe-recorder.test.ts +++ b/packages/compiler/src/node-islands/probe-recorder.test.ts @@ -72,3 +72,25 @@ test("the probe never evaluates the program's own source", () => { expect(existsSync(join(root, "src/evaluated-program"))).toBe(false); expect(existsSync(join(root, "src/evaluated-shared"))).toBe(false); }); + +test("a package that catches the refusal does not defer, nor does one that loads it later", () => { + const root = project({ + "src/config.js": marker("config"), + "node_modules/catches/package.json": '{ "name": "catches" }', + "node_modules/catches/index.js": + 'let config;\ntry { config = require("../../src/config.js"); } catch { config = { fallback: true }; }\nmodule.exports = config;\n', + "node_modules/later/package.json": '{ "name": "later" }', + "node_modules/later/index.js": 'module.exports = { config: require("catches") };\n', + "node_modules/clean/package.json": '{ "name": "clean" }', + "node_modules/clean/index.js": "module.exports = { ok: true };\n", + }); + const results = probe(root, [ + join(root, "node_modules/catches/index.js"), + join(root, "node_modules/later/index.js"), + join(root, "node_modules/clean/index.js"), + ]); + const refusal = `the build does not evaluate ${join(root, "src/config.js")}, a file of the program`; + expect(results.map((r) => r.throws)).toEqual([refusal, refusal, undefined]); + expect(results.map((r) => r.root === undefined)).toEqual([true, true, false]); + expect(existsSync(join(root, "src/evaluated-config"))).toBe(false); +}); diff --git a/packages/compiler/src/node-islands/probe-recorder.ts b/packages/compiler/src/node-islands/probe-recorder.ts index af29be084..33a357b43 100644 --- a/packages/compiler/src/node-islands/probe-recorder.ts +++ b/packages/compiler/src/node-islands/probe-recorder.ts @@ -21,7 +21,8 @@ * observable by other island modules evaluated out of order); * - every file the load required (the module's closure), and a throw; * a load that resolves a file outside every node_modules directory (the - * program's own source) throws before that file evaluates; + * program's own source) throws before that file evaluates, and its + * record is that throw even when the load catches it; * * and takes the interface snapshot of the module's value (snapshot.ts). * Calls made by Node's own internals (the module loader reading files, @@ -190,6 +191,11 @@ exports.probe = function probe(input) { const inputs = [], volatile = [], effects = [], tty = new Map(), envKeys = new Map(); const files = new Set(); let recording = false; + // The first program file the current load asked for (the resolve hook), + // and, for every file a load with a refusal evaluated, that program file: + // the module cache keeps what such a file exported without it. + let refused = null; + const refusedBy = new Map(); const note = (list, what) => { if (recording && !internalCaller()) list.push(list === volatile ? what : what + " (" + lastCaller + ")"); }; @@ -440,8 +446,10 @@ exports.probe = function probe(input) { const result = nextResolve(specifier, context); if (recording && typeof result.url === "string" && result.url.startsWith("file:")) { const file = require("node:url").fileURLToPath(result.url); - if (!/[\\/]node_modules[\\/]/.test(file)) + if (!/[\\/]node_modules[\\/]/.test(file)) { + if (refused === null) refused = file; throw new Error("the build does not evaluate " + file + ", a file of the program"); + } files.add(file); } return result; @@ -511,6 +519,7 @@ exports.probe = function probe(input) { pendingTimers.clear(); loadPromises.clear(); tty.clear(); envKeys.clear(); files.clear(); + refused = null; files.add(path); currentFiles = files; currentPackageDir = null; @@ -522,6 +531,15 @@ exports.probe = function probe(input) { recording = true; try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); } recording = false; + // A load that catches the refusal evaluates a fallback the executable + // never runs: its exports are not the module's, and neither are those of + // a later load that reaches one of its files through the module cache. + if (refused === null) + for (const f of files) if (refusedBy.has(f)) { refused = refusedBy.get(f); break; } + if (refused !== null) { + threw = "the build does not evaluate " + refused + ", a file of the program"; + for (const f of files) if (!refusedBy.has(f)) refusedBy.set(f, refused); + } const after = shot(); const root = threw === undefined ? value(exportsValue, 0) : undefined; const mutations = []; From 0ad917929e19e5da0382236b5246021d3407c7cd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 01:51:42 -0500 Subject: [PATCH 31/44] Check http.request options as Node does --- .../src/frontend/lowering/lower-server.ts | 33 ++++++++++++++- .../http-client-request-path-check.ts.json | 6 +++ .../http-request-option-checks.cjs.json | 6 +++ packages/runtime/src/scr_http.c | 41 ++++++++++++------- .../corpus/http-client-request-path-check.ts | 30 ++++++++++++++ tests/corpus/http-request-option-checks.cjs | 34 +++++++++++++++ 6 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-request-path-check.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-option-checks.cjs.json create mode 100644 tests/corpus/http-client-request-path-check.ts create mode 100644 tests/corpus/http-request-option-checks.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 7ba3f7191..0da4e3a05 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -6499,6 +6499,33 @@ function lowerHttp2ModuleCall( ); } +/** The scalar request options the literal walk lowers, with the type it + * lowers each one at. */ +const LITERAL_REQUEST_OPTION_KINDS: ReadonlyMap = new Map([ + ["hostname", "string"], + ["host", "string"], + ["port", "f64"], + ["path", "string"], + ["method", "string"], + ["timeout", "f64"], + ["maxHeaderSize", "f64"], +]); + +/** Whether a literal's scalar option holds a value of another type than the + * literal walk lowers it at, which sends the literal to the runtime walk. */ +function runtimeCheckedOption( + lowerer: Lowerer, + prop: ts.PropertyAssignment | ts.ShorthandPropertyAssignment, +): boolean { + const want = LITERAL_REQUEST_OPTION_KINDS.get( + (prop.name as ts.Identifier | ts.StringLiteral).text, + ); + if (want === undefined) return false; + const value = ts.isPropertyAssignment(prop) ? prop.initializer : prop.name; + const t = lowerer.mapTypeOf(lowerer.typeOf(value)); + return t !== null && t.kind !== want; +} + /** The checked-dynamic options value of a request whose options the * runtime reads: a non-literal options value (a RequestOptions variable — * checked-dynamic storage — or a record that converts to one), or a @@ -6528,7 +6555,11 @@ function runtimeRequestOptions( (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && (prop.name.text === "socketPath" || // a client certificate: the runtime request walk reads cert and key - (secureish && (prop.name.text === "cert" || prop.name.text === "key"))) + (secureish && (prop.name.text === "cert" || prop.name.text === "key")) || + // a scalar option whose value is not the walk's type (an untyped + // or union value, a string port): the runtime walk runs Node's + // checks on it (ERR_INVALID_ARG_TYPE with the option's name) + runtimeCheckedOption(lowerer, prop)) ); }); if (!needsRuntime) return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-request-path-check.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-request-path-check.ts.json new file mode 100644 index 000000000..c3c9bf7e7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-request-path-check.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/http-client-request-path-check.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-option-checks.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-option-checks.cjs.json new file mode 100644 index 000000000..630d70af2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-option-checks.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/http-request-option-checks.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 00faefdea..68e08a069 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -5158,6 +5158,24 @@ static void scr_http_client_conn_free(ScrHttpConn *conn) { /* ── http.request / http.get ─────────────────────────────────────────── */ +/* ClientRequest's path check: characters outside U+0021..U+00FF throw + * ERR_UNESCAPED_CHARACTERS. In UTF-8, U+0080..U+00FF are the C2/C3 lead + * bytes. */ +static bool scr_http_request_path_valid(const ScrStr *path /*borrowed*/) { + for (size_t i = 0; i < path->len; i++) { + unsigned char ch = (unsigned char)path->data[i]; + if (ch >= 0x21 && ch < 0x80) continue; + if (ch == 0xc2 || ch == 0xc3) { + i++; + continue; + } + static const char msg[] = "Request path contains unescaped characters"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_UNESCAPED_CHARACTERS"); + return false; + } + return true; +} + static ScrHttpClientReq *scr_http_request_impl(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, @@ -5165,11 +5183,16 @@ static ScrHttpClientReq *scr_http_request_impl(ScrStr *host /*borrowed*/, double ScrHttpRespFn fn, int default_port, void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, ScrNetSocket *presock /*moves, nullable*/) { - /* Node's ClientRequest constructor validates the method token - * SYNCHRONOUSLY (RFC 9110 tchar, at least one): the catchable + /* Node's ClientRequest constructor validates the path, then the method + * token (RFC 9110 tchar, at least one): the catchable * ERR_INVALID_HTTP_TOKEN TypeError with Node's exact message, the raw * method text inside the quotes. Moved-in arguments release — the * request never exists. */ + if (!scr_http_request_path_valid(path)) { + if (cb) scr_closure_release(cb); + if (presock) scr_net_sock_release(presock); + return NULL; + } bool method_ok = method->len > 0; for (size_t i = 0; i < method->len && method_ok; i++) { unsigned char ch = (unsigned char)method->data[i]; @@ -7435,19 +7458,7 @@ bool scr_http_req_opts_read(const ScrDyn *opts, bool secure, const char *api, Sc scr_http_req_opts_free(o); return false; } - bool bad = false; - for (size_t i = 0; i < o->path->len && !bad; i++) { - unsigned char ch = (unsigned char)o->path->data[i]; - if (ch < 0x21) bad = true; - else if (ch >= 0x80) { - /* U+0080..U+00FF (lead byte C2/C3) pass; anything above is unescaped */ - if (ch != 0xc2 && ch != 0xc3) bad = true; - else i++; - } - } - if (bad) { - static const char msg[] = "Request path contains unescaped characters"; - scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_UNESCAPED_CHARACTERS"); + if (!scr_http_request_path_valid(o->path)) { scr_http_req_opts_free(o); return false; } diff --git a/tests/corpus/http-client-request-path-check.ts b/tests/corpus/http-client-request-path-check.ts new file mode 100644 index 000000000..72bdb5d87 --- /dev/null +++ b/tests/corpus/http-client-request-path-check.ts @@ -0,0 +1,30 @@ +// http.request checks the path before it sends anything: characters outside +// U+0021..U+00FF throw ERR_UNESCAPED_CHARACTERS and no request is made. +// Latin-1 characters and DEL pass. +import * as http from "node:http"; + +const paths = ["/plain", "/latinéÿ", "/del\u007f", "/space here", "/tab\there", "/wideĀ", "/emoji\u{1f600}"]; + +const server = http.createServer((req, res) => { + console.log("served", JSON.stringify(req.url)); + res.end(); +}); + +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as { port: number }).port; + for (const path of paths) { + try { + const req = http.request({ host: "127.0.0.1", port, path }); + req.on("error", () => {}); + req.destroy(); + console.log("accepted", JSON.stringify(path)); + } catch (error) { + const e = error as NodeJS.ErrnoException; + console.log("threw", JSON.stringify(path), e.name, e.code, e.message); + } + } + http.get({ host: "127.0.0.1", port, path: "/after" }, (res) => { + res.resume(); + res.on("end", () => server.close()); + }); +}); diff --git a/tests/corpus/http-request-option-checks.cjs b/tests/corpus/http-request-option-checks.cjs new file mode 100644 index 000000000..9ec3eb993 --- /dev/null +++ b/tests/corpus/http-request-option-checks.cjs @@ -0,0 +1,34 @@ +// http.request runs Node's option checks on untyped values in an options +// literal: a method, hostname or host that is not a string throws +// ERR_INVALID_ARG_TYPE naming the option, undefined and null take the +// defaults, and a port string that is not a port throws ERR_SOCKET_BAD_PORT. +'use strict'; +const http = require('http'); + +function attempt(label, fn) { + try { + fn(); + console.log(label, 'ok'); + } catch (e) { + console.log(label, e.name, e.code, e.message); + } +} + +for (const method of [-1, {}, true, []]) { + attempt('method', () => http.request({ method, path: '/' }, () => {})); +} +for (const hostname of [1, [], new Date(), false]) { + attempt('hostname', () => http.request({ hostname })); +} +for (const host of [NaN, {}]) { + attempt('host', () => http.request({ host })); +} +[undefined, null, 'GET', 'post'].forEach((method) => { + attempt(`method ${method}`, () => { + const req = http.request({ method, port: 1, agent: false }); + req.on('error', () => {}); + req.destroy(); + }); +}); +const port = 'x'; +attempt('port', () => http.request({ port })); From 5a95e8a0769bbc7991222cf9cf7870dc6b43d94a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 03:43:48 -0500 Subject: [PATCH 32/44] Compile scr_intl.c with the ICU headers in the COMMON-symbol test common-symbols.test.ts compiles every runtime source with the vendored include paths only. scr_intl.c includes ICU's C API headers, which the runtime pack build takes from the fetched ICU release, so on a host without system ICU headers both of the test's compiles failed with "'unicode/ubrk.h' file not found", and the aarch64-macos compile failed on every host. The test now passes the same source/common and source/i18n include paths for scr_intl.c, through icu-data.mjs's ensureIcuSources. --- packages/runtime/test/common-symbols.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/runtime/test/common-symbols.test.ts b/packages/runtime/test/common-symbols.test.ts index 344bd01d6..33930c2a4 100644 --- a/packages/runtime/test/common-symbols.test.ts +++ b/packages/runtime/test/common-symbols.test.ts @@ -2,6 +2,7 @@ import { execFile, spawnSync } from "node:child_process"; import { mkdir, readdir, readFile } from "node:fs/promises"; import { availableParallelism } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { expect, test } from "vitest"; @@ -10,6 +11,19 @@ const testDir = import.meta.dirname; const srcDir = join(testDir, "../src"); const vendorDir = join(testDir, "../vendor"); const buildDir = join(testDir, "build", "common-symbols"); +const repoRoot = join(testDir, "../../.."); + +/** scr_intl.c includes ICU's C API headers from the fetched ICU release, as + * the runtime pack build compiles it (runtime-pack-common's icu-data.mjs). */ +async function icuIncludeFlags(): Promise { + const { ensureIcuSources } = (await import( + pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/icu-data.mjs")).href + )) as { ensureIcuSources: (cacheDir: string) => Promise }; + const root = await ensureIcuSources( + join(repoRoot, "node_modules", ".cache", "scriptc-icu-sources"), + ); + return ["-I", join(root, "source", "common"), "-I", join(root, "source", "i18n")]; +} /** COMMON globals in every runtime source compiled with `compiler`, in the * plain and worker configurations, as "file (defines): symbol" lines. */ @@ -22,6 +36,7 @@ async function commonSymbols(label: string, compiler: string[]): Promise [[], ["-DSCR_WORKERS"]].map((defines) => ({ name, defines })), ); + const icu = await icuIncludeFlags(); const common: string[] = []; let next = 0; const worker = async (): Promise => { @@ -39,6 +54,7 @@ async function commonSymbols(label: string, compiler: string[]): Promise Date: Fri, 30 Oct 2026 03:49:17 -0500 Subject: [PATCH 33/44] Report `import source` as unsupported instead of failing to decode it TypeScript 7.1's protocol 9 marks an `import source` clause with phase bits 3, which the decoder rejected, so a 7.1 checker failed the whole build with "TypeScript AST: `import source` is not supported". The decoder now keeps the phase, `phaseModifier` reports it as SOURCE_PHASE_MODIFIER, and preflight reports SC1090 at the declaration. --- packages/compiler/src/frontend/program.ts | 9 ++++ .../src/frontend/source-phase-imports.test.ts | 46 +++++++++++++++++++ .../compiler/src/frontend/ts7/ast-node.ts | 14 +++++- .../frontend/ts7/ast-wire-protocols.test.ts | 21 +++++++-- .../compiler/src/frontend/ts7/ast-wire.ts | 7 ++- 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/src/frontend/source-phase-imports.test.ts diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 702341e68..0ee71f885 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -43,6 +43,7 @@ import { isFrontendIsland } from "../node-islands/boundary.js"; import { builtinModules } from "node:module"; import { dirname, isAbsolute, resolve } from "node:path"; import * as ts from "./ts7/adapter.js"; +import { isSourcePhaseImport } from "./ts7/ast-node.js"; import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; import { commonJsModuleSyntaxDiag, @@ -6075,6 +6076,14 @@ function preflight7(load: LoadResult): { } if (!ts.isImportDeclaration(stmt)) continue; if (erasedTypeOnlyImport(stmt)) continue; + // `import source x from "./x.wasm"` (TypeScript 7.1) binds the module's source object, such + // as a WebAssembly.Module; compiled programs do not support it yet. + if (isSourcePhaseImport(stmt.importClause)) { + diags.push( + unsupportedDiag("SC1090", locOf7(stmt), "source phase imports (`import source`)"), + ); + continue; + } // Import-like nodes whose specifier the parse could not shape into a // string literal — the jsdoc `/** @import x = require(...) */` diff --git a/packages/compiler/src/frontend/source-phase-imports.test.ts b/packages/compiler/src/frontend/source-phase-imports.test.ts new file mode 100644 index 000000000..13bbbaec2 --- /dev/null +++ b/packages/compiler/src/frontend/source-phase-imports.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { checkPreflightTs7 } from "./program.js"; +import { Ts7Api } from "./ts7/api.js"; +import { Ts7Host } from "./ts7/program-host.js"; +import { Ts7RpcClient } from "./ts7/rpc-client.js"; +import { TS7_FILE_SYSTEM_CALLBACKS } from "./ts7/rpc-filesystem.js"; +import { spawnTs7Wire } from "./ts7/rpc-process.js"; +import { ts7TestServers } from "./ts7/test-servers.js"; + +test("an `import source` declaration from tsc 7.1 is reported as unsupported (SC1090)", () => { + const server = ts7TestServers().find((candidate) => candidate.ts71)!; + const dir = mkdtempSync(join(tmpdir(), "scriptc-source-phase-")); + const entry = join(dir, "main.ts"); + writeFileSync(join(dir, "package.json"), '{"type":"module"}'); + const declaration = 'import source mod from "./x.wasm";'; + writeFileSync(entry, `${declaration}\nconsole.log(typeof mod);\n`); + const host = new Ts7Host( + (options) => + new Ts7Api( + new Ts7RpcClient( + spawnTs7Wire(server.executable, [ + "--api", + "--cwd", + options.cwd, + `--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`, + ]), + ), + options.fs, + options.collectTiming, + ), + { cwd: dir }, + ); + try { + const { diags } = checkPreflightTs7(entry, host); + const unsupported = diags.filter((diag) => diag.code === "SC1090"); + expect(unsupported.map(({ message, loc }) => [message, loc.start, loc.end])).toEqual([ + ["source phase imports (`import source`) are not supported yet", 0, declaration.length], + ]); + } finally { + host.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/src/frontend/ts7/ast-node.ts b/packages/compiler/src/frontend/ts7/ast-node.ts index 7120b90d4..a66d5ddc0 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.ts @@ -14,7 +14,7 @@ import { type AstFileReference, type AstNodeHandle, } from "./ast-wire.js"; -import type { SourceFile } from "./ast-types.js"; +import type { ImportClause, SourceFile } from "./ast-types.js"; import { SyntaxKind, NodeFlags } from "./enums.js"; /** Receives the file name of a source file whose raw text or line @@ -32,6 +32,17 @@ export function observeSourceText(observer: ((fileName: string) => void) | undef * list field returns a new AstNodeList over the same array, so `nodes` keeps * its identity and a decoded file holds no list objects. The position is * decoded from the wire when it is read; most readers only use `nodes`. */ +/** An import clause's `phaseModifier` for TypeScript 7.1's `import source`, + * which TypeScript 7.0's syntax kinds have no keyword for. */ +export const SOURCE_PHASE_MODIFIER = -1; + +/** Whether `clause` is an `import source` clause (`SOURCE_PHASE_MODIFIER`). */ +export function isSourcePhaseImport(clause: ImportClause | undefined): boolean { + if (clause === undefined) return false; + const phase: number | undefined = clause.phaseModifier; + return phase === SOURCE_PHASE_MODIFIER; +} + export class AstNodeList { constructor( private readonly wire: AstWireFile, @@ -623,6 +634,7 @@ export class AstNode { get phaseModifier(): number | undefined { if (this.kind !== AstKind.ImportClause) return undefined; const value = (this.data >>> 24) & 3; + if (value === 3) return SOURCE_PHASE_MODIFIER; return value === 1 ? AstKind.TypeKeyword : value === 2 ? AstKind.DeferKeyword : undefined; } get token(): number | undefined { diff --git a/packages/compiler/src/frontend/ts7/ast-wire-protocols.test.ts b/packages/compiler/src/frontend/ts7/ast-wire-protocols.test.ts index 36aca02dd..666e8a2fe 100644 --- a/packages/compiler/src/frontend/ts7/ast-wire-protocols.test.ts +++ b/packages/compiler/src/frontend/ts7/ast-wire-protocols.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; -import { AstFile } from "./ast-node.js"; +import { AstFile, SOURCE_PHASE_MODIFIER } from "./ast-node.js"; import { AstWireFile, PROTOCOL_VERSION_7_1 } from "./ast-wire.js"; import { AstKind, @@ -118,6 +118,12 @@ test("protocol 9 nodes that TypeScript 7.0 has no form for are rejected", () => view.setUint32(at + NODE_OFFSET_DATA, view.getUint32(at + NODE_OFFSET_DATA, true) | 4, true), ); expect(() => new AstWireFile(attributes)).toThrow("import attributes on a module declaration"); + const version = bytes("protocol9", "main.ts"); + version[3] = 6; + expect(() => new AstWireFile(version)).toThrow("unsupported protocol version 6"); +}); + +test("protocol 9 import source clauses decode with the source phase", () => { const source = changed("main.ts", AstKind.ImportClause, (view, at) => view.setUint32( at + NODE_OFFSET_DATA, @@ -125,8 +131,13 @@ test("protocol 9 nodes that TypeScript 7.0 has no form for are rejected", () => true, ), ); - expect(() => new AstWireFile(source)).toThrow("`import source`"); - const version = bytes("protocol9", "main.ts"); - version[3] = 6; - expect(() => new AstWireFile(version)).toThrow("unsupported protocol version 6"); + const file = new AstFile(source); + let sourceClauses = 0; + for (let index = 1; index < file.wire.nodeCount; index++) + if ( + file.wire.kind(index) === AstKind.ImportClause && + file.node(index).phaseModifier === SOURCE_PHASE_MODIFIER + ) + sourceClauses++; + expect(sourceClauses).toBe(1); }); diff --git a/packages/compiler/src/frontend/ts7/ast-wire.ts b/packages/compiler/src/frontend/ts7/ast-wire.ts index 14b1c4548..2e74963a1 100644 --- a/packages/compiler/src/frontend/ts7/ast-wire.ts +++ b/packages/compiler/src/frontend/ts7/ast-wire.ts @@ -143,8 +143,9 @@ export function syntaxKind71(kind: number): number { /** The node table of a protocol 7.1 response in TypeScript 7.0's terms: each * kind above SourceKeyword one lower, and each module declaration's child * mask without the attributes slot. Throws for the nodes 7.0 has no form - * for: a SourceKeyword node, a module declaration with import attributes, - * and an `import source` clause. */ + * for: a SourceKeyword node and a module declaration with import attributes. + * An `import source` clause keeps its phase, which `phaseModifier` reports as + * SOURCE_PHASE_MODIFIER. */ function wordsAs70(words: Uint32Array, count: number): Uint32Array { const result = new Uint32Array(words); for (let index = 1; index < count; index++) { @@ -159,8 +160,6 @@ function wordsAs70(words: Uint32Array, count: number): Uint32Array { if ((data & 4) !== 0) throw new AstDecodeError("import attributes on a module declaration are not supported"); result[word + WORD_DATA] = ((data & ~0xff) | (data & 3) | ((data & 8) >>> 1)) >>> 0; - } else if (kind === AstKind.ImportClause && ((data >>> 24) & 3) === 3) { - throw new AstDecodeError("`import source` is not supported"); } } return result; From 834bccdcd0cdc272eb83513eb3b03ade112b42c9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 04:01:39 -0500 Subject: [PATCH 34/44] Keep records cast from unknown[] elements to dictionary types as live views --- .../record-identity-and-property-order.mdx | 2 +- .../limitations/runtime-type-checks.mdx | 2 + .../src/frontend/lowering/class-protocols.ts | 75 +++++++++++++++---- .../record-unknown-dictionary-views.ts.json | 6 ++ .../corpus/record-unknown-dictionary-views.ts | 37 +++++++++ .../record-unknown-dictionary-views.ts.json | 1 + tests/harness/dyncheck.test.ts | 34 +++++++++ 7 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json create mode 100644 tests/corpus/record-unknown-dictionary-views.ts create mode 100644 tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index fc0b2be92..c76bf068a 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -4,7 +4,7 @@ Empty record layouts are also snapshots because they have no property storage. `scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal. Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage. -A record or class instance converted to a string-keyed dictionary type such as `Record`, directly or through a `{}`, `object` or `unknown` binding or parameter, keeps its identity when the program writes through, compares or inspects values of that dictionary type. +A record or class instance converted to a string-keyed dictionary type such as `Record`, directly, through a `{}`, `object` or `unknown` binding or parameter, or as an element of a constant `unknown[]` array that holds such records, keeps its identity when the program writes through, compares or inspects values of that dictionary type. Every dictionary type with the same value type then uses checked dynamic storage, which is slower than a native dictionary. Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js. These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects. diff --git a/docs/content/limitations/runtime-type-checks.mdx b/docs/content/limitations/runtime-type-checks.mdx index f6ead482b..095f3d977 100644 --- a/docs/content/limitations/runtime-type-checks.mdx +++ b/docs/content/limitations/runtime-type-checks.mdx @@ -1,6 +1,8 @@ Casts from values whose type is only known at runtime validate the value. For example, `JSON.parse(s) as Config` throws a catchable error if the data does not match `Config`, identifying the offending path, such as `expected number at $.port, got string`. A TypeScript assertion alone does not perform this validation when run under Node.js. +A record or class instance cast from `unknown` to a dictionary type such as `Record` keeps its identity, and the cast does not validate it. +Reading a value that does not match the dictionary's value type throws a `TypeError`, such as `expected number at $, got string`, where Node.js returns the value. Casts between program classes are checked too: casting an instance to a class it does not extend throws a `TypeError`, where Node.js reads the members structurally. `SC6004` reports calls that pass such an instance to a function that casts it. diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 76a9b7f95..dce1bc191 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -973,15 +973,53 @@ export class ClassProtocols { callsNamed: (name: string) => readonly (ts.CallExpression | ts.NewExpression)[], ): boolean { if (this.isProgramClassInstance(type) || this.isRecordWithFields(type)) return true; - if (!isDynamicView(this.checker, type) || !ts.isIdentifier(source)) return false; + if (!isDynamicView(this.checker, type)) return false; + const holdsObjectType = (held: ts.Type | undefined): boolean => + held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held)); + const holdsObject = (expr: ts.Expression): boolean => + holdsObjectType(attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr)))); + const constInitializer = (name: ts.Identifier): ts.Expression | undefined => { + const decl = attempt(() => this.checker.getSymbolAtLocation(name))?.valueDeclaration; + return decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ? decl.initializer + : undefined; + }; + if (ts.isElementAccessExpression(source)) { + // An element of a constant `unknown[]` binding holds what the + // binding's initializer stores: the elements of an array of records + // or class instances, or what a `map` callback returns. + let base = source.expression; + while (ts.isParenthesizedExpression(base)) base = base.expression; + const initializer = ts.isIdentifier(base) ? constInitializer(base) : undefined; + if (initializer === undefined) return false; + const array = attempt(() => this.checker.getTypeAtLocation(initializer)); + if ( + array !== undefined && + this.checker.isArrayType(array) && + holdsObjectType(this.checker.getTypeArguments(array as ts.TypeReference)[0]) + ) + return true; + let call = initializer; + while (ts.isParenthesizedExpression(call)) call = call.expression; + if ( + !ts.isCallExpression(call) || + !ts.isPropertyAccessExpression(call.expression) || + call.expression.name.text !== "map" + ) + return false; + const callback = call.arguments.nodes[0]; + return ( + callback !== undefined && + (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) && + functionResults(callback).some(holdsObject) + ); + } + if (!ts.isIdentifier(source)) return false; const decl = attempt(() => this.checker.getSymbolAtLocation(source))?.valueDeclaration; if (decl === undefined) return false; - const holdsObject = (expr: ts.Expression): boolean => { - const held = attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr))); - return ( - held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held)) - ); - }; if (ts.isParameter(decl)) { // A parameter holds what its callers pass: the calls of the function // by name whose signature resolves to it. @@ -1000,13 +1038,8 @@ export class ClassProtocols { ); }); } - return ( - ts.isVariableDeclaration(decl) && - decl.initializer !== undefined && - ts.isVariableDeclarationList(decl.parent) && - (decl.parent.flags & ts.NodeFlags.Const) !== 0 && - holdsObject(decl.initializer) - ); + const initializer = constInitializer(source); + return initializer !== undefined && holdsObject(initializer); } /** A program object type with declared properties that is not a class @@ -1371,6 +1404,20 @@ const IDENTITY_METHODS = new Set([ "includes", ]); +/** The values a function expression answers: an arrow's expression body, + * or the operands of its own return statements. */ +function functionResults(fn: ts.ArrowFunction | ts.FunctionExpression): ts.Expression[] { + if (!ts.isBlock(fn.body)) return [fn.body]; + const results: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) results.push(node.expression); + ts.forEachChild(node, visit); + }; + ts.forEachChild(fn.body, visit); + return results; +} + /** An expression that denotes an object that already exists, not a fresh * literal or call result. */ function isExistingObject(node: ts.Expression): boolean { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json new file mode 100644 index 000000000..2154966eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-unknown-dictionary-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-unknown-dictionary-views.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-unknown-dictionary-views.ts b/tests/corpus/record-unknown-dictionary-views.ts new file mode 100644 index 000000000..0040dea45 --- /dev/null +++ b/tests/corpus/record-unknown-dictionary-views.ts @@ -0,0 +1,37 @@ +// Records stored as `unknown` array elements and cast to a dictionary type +// keep their identity: writes through the view reach the record, and reads +// of fields of the dictionary's value type answer the record's values. The +// elements come from map(), an array literal and an array of records. +type Stats = { hits: number; misses: number; name: string }; +const stats: Stats[] = [ + { hits: 1, misses: 0, name: "a" }, + { hits: 5, misses: 2, name: "b" }, +]; + +const mapped: unknown[] = stats.map((s) => s as unknown); +const first = mapped[0] as Record; +first["hits"] = first["hits"]! + 1; +console.log(stats[0]!.hits, (first as unknown) === stats[0], first["misses"]); + +const listed: unknown[] = [stats[1]]; +const second = listed[0] as Record; +second["misses"] = second["misses"]! * 10; +console.log(stats[1]!.misses, second["hits"]); + +const copied: unknown[] = stats; +const again = copied[1] as Record; +again["hits"] = 50; +console.log(stats[1]!.hits, again === second); + +const returned: unknown[] = stats.map((s) => { + return s as unknown; +}); +const third = returned[0] as Record; +third["misses"] = 7; +console.log(stats[0]!.misses, third === first); + +function bump(view: Record, key: "hits" | "misses"): void { + view[key] = view[key]! + 100; +} +bump(mapped[1] as Record, "hits"); +console.log(stats[1]!.hits, JSON.stringify(stats)); diff --git a/tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json b/tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json new file mode 100644 index 000000000..d2b66ce43 --- /dev/null +++ b/tests/harness/corpus-costs/record-unknown-dictionary-views.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 53000 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 97def0cc6..3c04f24d2 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -209,6 +209,40 @@ console.log("unreachable", c.host); expect(r.stderr).toContain("Uncaught TypeError: expected number at $.port, got string"); }); + test("a record cast from unknown to a dictionary throws at its mismatched read", async () => { + // An element of an `unknown[]` keeps the record's identity, as an + // `unknown` binding does: the write lands, and only the read of a + // field the dictionary's value type does not admit throws. + const r = await compileAndRun( + "dictionary-view-mismatch", + `type Stats = { hits: number; name: string }; +const stats: Stats[] = [{ hits: 1, name: "a" }, { hits: 1, name: "b" }]; +function attempt(label: string, view: Record): void { + view["hits"] = view["hits"]! + 1; + try { + console.log(label, view["name"]); + } catch (error) { + console.log(label, (error as Error).name, (error as Error).message); + } +} +const mapped: unknown[] = stats.map((s) => s as unknown); +attempt("mapped", mapped[0] as Record); +const listed: unknown[] = [stats[1]]; +attempt("listed", listed[0] as Record); +console.log(stats[0]!.hits, stats[1]!.hits); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "mapped TypeError expected number at $, got string", + "listed TypeError expected number at $, got string", + "2 2", + "", + ].join("\n"), + ); + }); + test("schema-directed JSON.parse declines to the checked route with identical errors", async () => { // `JSON.parse(text) as T` for plain record/array targets parses in one // pass and reruns the checked-dynamic route whenever it declines, so From 895dd44bd71f890b7084f57999049a3ec942b880 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 04:09:40 -0500 Subject: [PATCH 35/44] Keep evaluation order and listener signatures with any-name emitters and union spreads A distributed union spread evaluates its source before the dispatch, so the properties written before the spread run after it. When the source may have effects (a call that changes a value an earlier property reads), those properties must now be constants; otherwise the literal takes the checked-dynamic merge, which keeps Node's order. A listener registered under an unrestricted event name was not recorded in the program's event scan. A prefixed or suffixed emit with no matching pattern listener was then lowered to `false` without dispatching, so the any-name listener never ran. The scan now records such registrations, and computed-name emits dispatch whenever one exists. The same registrations can place a listener of any signature in the bucket of a literal event. listeners(), rawListeners() and events.getEventListeners() of a literal event returned that bucket typed by the event's tuple, so calling an element passed the listener arguments of other types. These calls are now refused once the program registers a listener under an unrestricted name, as they already are for pattern registrations. --- .../limitations/builtin-apis/eventemitter.mdx | 3 +- .../lowering/expressions/object-literals.ts | 52 +++++++++++++++++-- .../frontend/lowering/lower-event-emitter.ts | 47 ++++++++++------- .../compiler/src/frontend/lowering/lowerer.ts | 4 ++ .../diagnostics/ee-any-name-listeners.ts.json | 6 +++ tests/corpus/emitter-any-event-names.ts | 13 +++++ tests/corpus/union-spread-distribution.ts | 20 +++++++ tests/diagnostics/ee-any-name-listeners.ts | 13 +++++ .../ee-any-name-listeners.ts.txt | 17 ++++++ 9 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-any-name-listeners.ts.json create mode 100644 tests/diagnostics/ee-any-name-listeners.ts create mode 100644 tests/harness/__snapshots__/ee-any-name-listeners.ts.txt diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index 0e085c56f..66361d1ed 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -1,6 +1,7 @@ - **EventEmitter:** Literal event names use fixed argument tuples. Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion. - Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported. + Unrestricted string names on plain emitters register and emit through checked-dynamic listeners, with Node's `error` and meta-event behavior. + Unrestricted names on streams, symbols, prefixed or suffixed names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported; once the program registers a listener under an unrestricted name, that covers every name. An emitter reached through an untyped value shares one listener list with its typed uses, so untyped registrations and emits reach typed listeners and typed emits reach untyped listeners. An untyped `emit` of an event that has a listener with typed parameters converts its arguments to the event's typed tuple before any listener runs, and throws a `TypeError` when they do not convert. Through an untyped value, `listeners()` and `rawListeners()` of an event with a listener that has typed parameters are unsupported, and `emit('error')` of an object that is not an Error with no `error` listener is unsupported. diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 18668ea98..0641e8774 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -186,6 +186,43 @@ function pureKeyExpr(lowerer: Lowerer, expr: ts.Expression): boolean { return false; } +/** Whether expr reads no state that evaluating other code could change: + * literals, `const` bindings, and unary operators over those. */ +function constantInitializer(lowerer: Lowerer, expr: ts.Expression): boolean { + if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) + return constantInitializer(lowerer, expr.expression); + if (ts.isAsExpression(expr) || ts.isTypeAssertion(expr)) + return constantInitializer(lowerer, expr.expression); + if (ts.isStringLiteralLike(expr) || ts.isNumericLiteral(expr)) return true; + if ( + expr.kind === ts.SyntaxKind.TrueKeyword || + expr.kind === ts.SyntaxKind.FalseKeyword || + expr.kind === ts.SyntaxKind.NullKeyword + ) + return true; + if ( + ts.isPrefixUnaryExpression(expr) && + (expr.operator === ts.SyntaxKind.MinusToken || + expr.operator === ts.SyntaxKind.PlusToken || + expr.operator === ts.SyntaxKind.ExclamationToken || + expr.operator === ts.SyntaxKind.TildeToken) + ) + return constantInitializer(lowerer, expr.operand); + if (ts.isIdentifier(expr)) { + const decls = lowerer.checker.getSymbolAtLocation(expr)?.declarations ?? []; + return ( + decls.length > 0 && + decls.every( + (d) => + ts.isVariableDeclaration(d) && + ts.isVariableDeclarationList(d.parent) && + (ts.getCombinedNodeFlags(d) & ts.NodeFlags.Const) !== 0, + ) + ); + } + return false; +} + /** A property's field name: identifier/string-literal keys directly, * foldable computed keys through literalComputedKey (the syntax pass * fenced everything else). */ @@ -2729,10 +2766,19 @@ function lowerDistributedUnionSpread( if (srcNode === null) return null; const site = srcNode; // The source evaluates before the dispatch, so everything written before - // it must be free of effects. + // it must be free of effects. When the source itself may have effects, an + // earlier property may also not read a value they could change: those + // properties must be constants. + const sourcePure = pureKeyExpr(lowerer, site); for (const prop of props.slice(0, spreadIndex)) { - if (ts.isShorthandPropertyAssignment(prop)) continue; - if (ts.isPropertyAssignment(prop) && pureKeyExpr(lowerer, prop.initializer)) continue; + if (sourcePure && ts.isShorthandPropertyAssignment(prop)) continue; + if ( + ts.isPropertyAssignment(prop) && + (sourcePure + ? pureKeyExpr(lowerer, prop.initializer) + : constantInitializer(lowerer, prop.initializer)) + ) + continue; return null; } const siteType = lowerer.mapTypeOf(lowerer.typeOf(site)); diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 112415e70..ec5b85eb3 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -24,13 +24,15 @@ * same vector handles varying or inference-unknown JavaScript emit tuples * when every known listener accepts checked-dynamic parameters or no * parameters. Typed listener constraints keep the fixed-tuple refusal. - * The remaining cases are fenced: - * unrestricted names, symbol names, conflicting literal tuples, the - * meta-events' listener-function argument (meta listeners take at most + * Unrestricted names on plain emitters take the checked-dynamic receiver's + * bridge. The remaining cases are fenced: + * unrestricted names on streams, symbol names, conflicting literal tuples, + * the meta-events' listener-function argument (meta listeners take at most * the event name; dyn meta listeners fence — Node * passes the listener function second, which has no dyn conversion), and * listeners()/rawListeners() of an event with any dynamic - * registration (the bucket has no one honest element type). + * registration, or of any event once the program registers a listener + * under an unrestricted name (the bucket has no one honest element type). * * Two special names: 'error' is forced to the one-%Error tuple (emit * routes through emitter.emitError — no listener means the payload @@ -178,6 +180,7 @@ function emitterEvents(lowerer: Lowerer): Map { if (lowerer.emitterEventTable) return lowerer.emitterEventTable; const table = new Map(); const computedPatterns: ComputedEventPattern[] = []; + let anyNameListener = false; const sigOf = (name: string): EventSig => { let sig = table.get(name); if (!sig) @@ -320,6 +323,9 @@ function emitterEvents(lowerer: Lowerer): Map { ) { computedPatterns.push({ ...pattern, listener: !isEmit }); } + // A listener under an unrestricted name may receive any event + // (lowerFlexListenerCall's any-name path). + if (!pattern && !isEmit && streamSidesOf(lowerer, info) === null) anyNameListener = true; return; } const name = literal; @@ -379,6 +385,7 @@ function emitterEvents(lowerer: Lowerer): Map { } lowerer.emitterEventTable = table; lowerer.emitterComputedPatterns = computedPatterns; + lowerer.emitterAnyNameListener = anyNameListener; return table; } @@ -564,6 +571,21 @@ function patternMatchesName(pattern: ComputedEventPattern, key: string): boolean return name.startsWith(pattern.prefix) && name.endsWith(pattern.suffix); } +/** Whether a listener registered under a computed name may sit in the + * bucket of the event keyed `key`: a pattern registration that matches it, + * or any registration under an unrestricted name (a string, so never the + * errorMonitor key). Such a listener crossed + * the checked-dynamic boundary, so the bucket has no one static signature. */ +function computedListenerMayHold(lowerer: Lowerer, key: string): boolean { + emitterEvents(lowerer); + return ( + (lowerer.emitterAnyNameListener && key !== ERROR_MONITOR_EVENT) || + computedPatterns(lowerer).some( + (pattern) => pattern.listener && patternMatchesName(pattern, key), + ) + ); +} + /** A computed event can use the variable-arity dyn path only when its * syntax proves a nonempty prefix or suffix excluding internal events. */ function computedEventPattern( @@ -1641,6 +1663,7 @@ export function lowerEmitterMethodCall( const pattern = computedEventPatternOf(lowerer, args[0]!); const mayBeObserved = pattern === null || + lowerer.emitterAnyNameListener || computedPatternOverlapsReserved(pattern) || [...table].some( ([candidate, sig]) => @@ -1857,13 +1880,7 @@ export function lowerEmitterMethodCall( } const name = eventNameOf(lowerer, member, args[0]!); const sig = table.get(name); - if ( - sig?.dynListener || - flexibleJsEvent(name, sig) || - computedPatterns(lowerer).some( - (pattern) => pattern.listener && patternMatchesName(pattern, name), - ) - ) { + if (sig?.dynListener || flexibleJsEvent(name, sig) || computedListenerMayHold(lowerer, name)) { // A dyn-adapted registration means the runtime bucket can hold // originals of MIXED signatures — no one honest element type. lowerer.noLowering( @@ -2842,13 +2859,7 @@ export function lowerEventsEmitterHelper( const name = eventNameOf(lowerer, "events.getEventListeners", args[1]!); const table = emitterEvents(lowerer); const sig = table.get(name); - if ( - sig?.dynListener || - flexibleJsEvent(name, sig) || - computedPatterns(lowerer).some( - (pattern) => pattern.listener && patternMatchesName(pattern, name), - ) - ) { + if (sig?.dynListener || flexibleJsEvent(name, sig) || computedListenerMayHold(lowerer, name)) { lowerer.noLowering( `events.getEventListeners of the event '${name}'`, call, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b4864293b..e30c52e50 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2698,6 +2698,9 @@ export class Lowerer { requestInitPropMutatedSyms: Set | null = null; emitterEventTable: Map | null = null; emitterComputedPatterns: ComputedEventPattern[] = []; + /** Some site registers a listener under an unrestricted computed name + * (lower-event-emitter.ts emitterEvents). */ + emitterAnyNameListener = false; /** The EventTarget engine's handleEvent reader was lifted * (lower-event-target.ts noteEventListeners). */ handleEventReader = false; @@ -4724,6 +4727,7 @@ export class Lowerer { fixed("errorPropertyDispatchRevision", () => this.errorPropertyDispatchRevision), fixed("childAdditions", () => this.childAdditions), fixed("emitterEventTable", () => this.emitterEventTable), + fixed("emitterAnyNameListener", () => this.emitterAnyNameListener), fixed("globalThisWrittenNames", () => this.globalThisWrittenNames), fixed("importRetryModules", () => this.importRetryModules), fixed("localSubclassIndex", () => this.localSubclassIndex), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-any-name-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-any-name-listeners.ts.json new file mode 100644 index 000000000..19be830e7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/ee-any-name-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/ee-any-name-listeners.ts" + ], + "diags": [] +} diff --git a/tests/corpus/emitter-any-event-names.ts b/tests/corpus/emitter-any-event-names.ts index 6f07f526c..ebecba9ec 100644 --- a/tests/corpus/emitter-any-event-names.ts +++ b/tests/corpus/emitter-any-event-names.ts @@ -68,4 +68,17 @@ bus.emit(errName, new Error("handled")); bus.clear(); bus.emit("greet", "cleared"); console.log("names", emitter.eventNames().length); +// Prefixed and suffixed emits reach listeners registered by an +// unrestricted name. +const channels = new EventEmitter(); +function listen(name: string): void { + channels.on(name, (d: unknown) => console.log("channel", name, JSON.stringify(d))); +} +listen("user:1"); +listen("job.done"); +const userId = String(1); +const jobKind = ["jo", "b"].join(""); +console.log("prefixed", channels.emit("user:" + userId, { a: 1 })); +console.log("suffixed", channels.emit(`${jobKind}.done`, 2)); +console.log("unmatched", channels.emit("user:" + String(2), 3)); setTimeout(() => console.log("done"), 0); diff --git a/tests/corpus/union-spread-distribution.ts b/tests/corpus/union-spread-distribution.ts index e1a6b54a9..7239f19bf 100644 --- a/tests/corpus/union-spread-distribution.ts +++ b/tests/corpus/union-spread-distribution.ts @@ -132,3 +132,23 @@ const base: Cmd = { type: "prompt", message: "before" }; const copied = withId(base); if (base.type === "prompt") base.message = "after"; console.log(JSON.stringify(copied), JSON.stringify(base)); +// A spread source whose evaluation changes a value an earlier property +// reads: the earlier property keeps the value from before the spread. +type Src = { kind: "a"; x: number } | { kind: "b"; y: string }; +type Stamped = { kind: "a"; x: number; at: number } | { kind: "b"; y: string; at: number }; +let clock = 0; +function tick(s: Src): Src { + clock++; + return s; +} +const ticked: Src[] = [ + { kind: "a", x: 1 }, + { kind: "b", y: "s" }, +]; +for (const s of ticked) { + const stamped: Stamped = { at: clock, ...tick(s) }; + console.log(JSON.stringify(stamped), clock); +} +const constStamp = 7; +const fixed: Stamped = { at: constStamp, ...tick(ticked[0]!) }; +console.log(JSON.stringify(fixed), clock); diff --git a/tests/diagnostics/ee-any-name-listeners.ts b/tests/diagnostics/ee-any-name-listeners.ts new file mode 100644 index 000000000..2ec315198 --- /dev/null +++ b/tests/diagnostics/ee-any-name-listeners.ts @@ -0,0 +1,13 @@ +// A listener registered under an unrestricted name can sit in the bucket of +// any event, with any parameter types. So the typed listener array of a +// literal event is refused: calling its elements with the event's tuple +// would pass that listener arguments its parameters do not accept. +import { EventEmitter, getEventListeners } from "node:events"; + +const emitter = new EventEmitter(); +emitter.on("count", (n: number) => console.log(n + 1)); +const name = process.argv[2] ?? "count"; +emitter.on(name, (label: { text: string }) => console.log(label.text.length)); +type CountListener = (n: number) => void; +for (const listener of emitter.listeners("count")) (listener as CountListener)(41); +for (const listener of getEventListeners(emitter, "count")) (listener as CountListener)(41); diff --git a/tests/harness/__snapshots__/ee-any-name-listeners.ts.txt b/tests/harness/__snapshots__/ee-any-name-listeners.ts.txt new file mode 100644 index 000000000..44496d72c --- /dev/null +++ b/tests/harness/__snapshots__/ee-any-name-listeners.ts.txt @@ -0,0 +1,17 @@ +ee-any-name-listeners.ts:12:24 - error SC2020: 'listeners of the event 'count'' is part of the standard library types but has no scriptc lowering yet + + 11 | type CountListener = (n: number) => void; + 12 | for (const listener of emitter.listeners("count")) (listener as CountListener)(41); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~ + 13 | for (const listener of getEventListeners(emitter, "count")) (listener as CountListener)(41); + + hint: a listener of this event uses checked-dynamic dispatch, so the listener array has no one static element type — listenerCount(name) counts + +ee-any-name-listeners.ts:13:24 - error SC2020: 'events.getEventListeners of the event 'count'' is part of the standard library types but has no scriptc lowering yet + + 12 | for (const listener of emitter.listeners("count")) (listener as CountListener)(41); + 13 | for (const listener of getEventListeners(emitter, "count")) (listener as CountListener)(41); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 14 | + + hint: a listener of this event uses checked-dynamic dispatch, so the listener array has no one static element type — listenerCount(emitter, name) counts \ No newline at end of file From 50b99764835898670edfe1b5cd5db92856467dda Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 04:28:03 -0500 Subject: [PATCH 36/44] Use the runtime's rounding and libm definitions in musl executables floor, ceil and trunc in a musl executable came from zig's libc as weak bit-manipulation routines, and the runtime's date arithmetic and the compiler's rounding intrinsics call them. scr_glibc_math.c now applies to musl executables too: floor, ceil, trunc, round, sqrt, fmod, scalbn, ldexp and frexp are the same exactly specified definitions as on glibc. A static musl executable has no ifunc, so on x86_64 floor, ceil and trunc call through a pointer that their first call sets to the SSE4.1 rounding instruction when the CPU has it. Library artifacts keep the embedder's libm. Adds a test that builds the same program against glibc's libm and, for x86_64-linux-musl, against these definitions, and compares every result. --- packages/runtime/src/scr_glibc_math.c | 30 +++++++++- packages/runtime/src/scr_glibc_math.test.c | 61 +++++++++++++++++++++ packages/runtime/src/scr_glibc_math.test.ts | 52 ++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 packages/runtime/src/scr_glibc_math.test.c create mode 100644 packages/runtime/src/scr_glibc_math.test.ts diff --git a/packages/runtime/src/scr_glibc_math.c b/packages/runtime/src/scr_glibc_math.c index 2ef2ed2e7..cd7b25f51 100644 --- a/packages/runtime/src/scr_glibc_math.c +++ b/packages/runtime/src/scr_glibc_math.c @@ -8,12 +8,18 @@ * included. Defining them in the executable lets a program that uses no * other libm function link without libm.so (-lm is linked as needed), * which saves loading it at startup. Library builds keep the embedder's - * libm. */ + * libm. + * + * musl executables use them too: zig's libc, which the musl lane links, + * defines floor, ceil and trunc as weak bit-manipulation routines without + * SSE4.1, and these definitions replace them. A static musl executable has + * no ifunc, so on x86_64 floor, ceil and trunc call through a pointer that + * their first call sets. */ #include #include -#if defined(__GLIBC__) && !defined(SCR_LIB) +#if (defined(__GLIBC__) || defined(SCR_MUSL)) && !defined(SCR_LIB) /* The sanitizer runtime calls some of these functions while it initializes, * and the dynamic loader calls the resolvers before that: none of the code @@ -122,9 +128,29 @@ typedef double (*SgmUnary)(double); static SgmUnary sgm_resolve_floor(void) { return sgm_has_sse41() ? sgm_floor_sse41 : sgm_floor; } static SgmUnary sgm_resolve_ceil(void) { return sgm_has_sse41() ? sgm_ceil_sse41 : sgm_ceil; } static SgmUnary sgm_resolve_trunc(void) { return sgm_has_sse41() ? sgm_trunc_sse41 : sgm_trunc; } +#if defined(SCR_MUSL) +/* The first call resolves the implementation and stores it; threads that + * race on it store the same pointer. */ +#include +#define SGM_FIRST_CALL(name) \ + static double sgm_##name##_first(double x); \ + static _Atomic(SgmUnary) sgm_##name##_impl = sgm_##name##_first; \ + static double sgm_##name##_first(double x) { \ + SgmUnary impl = sgm_resolve_##name(); \ + atomic_store_explicit(&sgm_##name##_impl, impl, memory_order_relaxed); \ + return impl(x); \ + } \ + SGM_DEFINE double name(double x) { \ + return atomic_load_explicit(&sgm_##name##_impl, memory_order_relaxed)(x); \ + } +SGM_FIRST_CALL(floor) +SGM_FIRST_CALL(ceil) +SGM_FIRST_CALL(trunc) +#else SGM_DEFINE double floor(double x) __attribute__((ifunc("sgm_resolve_floor"))); SGM_DEFINE double ceil(double x) __attribute__((ifunc("sgm_resolve_ceil"))); SGM_DEFINE double trunc(double x) __attribute__((ifunc("sgm_resolve_trunc"))); +#endif #else SGM_DEFINE double floor(double x) { return sgm_floor(x); } SGM_DEFINE double ceil(double x) { return sgm_ceil(x); } diff --git a/packages/runtime/src/scr_glibc_math.test.c b/packages/runtime/src/scr_glibc_math.test.c new file mode 100644 index 000000000..159069e7c --- /dev/null +++ b/packages/runtime/src/scr_glibc_math.test.c @@ -0,0 +1,61 @@ +/* Prints the bits of floor, ceil, trunc, round, sqrt, fmod, scalbn, ldexp + * and frexp over a fixed set of inputs: NaN payloads, signed zeros, + * subnormals, the infinities, halfway cases and values around 2^52 (quiet + * NaNs only: frexp returns a signaling NaN unchanged where glibc's quiets + * it, which no JavaScript value can observe). The + * test (scr_glibc_math.test.ts) builds it against glibc's libm and, for the + * musl lane, against scr_glibc_math.c, and compares the output. */ +#include +#include +#include +#include + +static uint64_t bits(double d) { + uint64_t b; + memcpy(&b, &d, sizeof b); + return b; +} + +static double from_bits(uint64_t b) { + double d; + memcpy(&d, &b, sizeof d); + return d; +} + +/* volatile: the calls must reach the library, not constant folding. */ +static volatile double inputs[64]; +static size_t count; + +static void add(double d) { inputs[count++] = d; } + +int main(void) { + static const double values[] = { + 0.0, -0.0, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 0.49999999999999994, -0.49999999999999994, + 1.0, -1.0, 3.7, -3.7, 1e-310, -1e-310, 4.9e-324, 123456789.75, -123456789.25, + 4503599627370495.5, -4503599627370495.5, 4503599627370496.0, 9007199254740993.0, + 1e300, -1e300, 1.7976931348623157e308, + }; + for (size_t i = 0; i < sizeof values / sizeof values[0]; i++) add(values[i]); + add(from_bits(UINT64_C(0x7FF0000000000000))); + add(from_bits(UINT64_C(0xFFF0000000000000))); + add(from_bits(UINT64_C(0x7FF8000000000001))); + add(from_bits(UINT64_C(0xFFFC000000000123))); + for (size_t i = 0; i < count; i++) { + double x = inputs[i]; + int e = 0; + double m = frexp(x, &e); + printf("%016" PRIx64 " floor %016" PRIx64 " ceil %016" PRIx64 " trunc %016" PRIx64 + " round %016" PRIx64 " sqrt %016" PRIx64 " frexp %016" PRIx64 " %d\n", + bits(x), bits(floor(x)), bits(ceil(x)), bits(trunc(x)), bits(round(x)), + bits(sqrt(x)), bits(m), e); + for (size_t j = 0; j < count; j += 3) { + double y = inputs[j]; + printf(" fmod %016" PRIx64 " %016" PRIx64 "\n", bits(y), bits(fmod(x, y))); + } + static const int exps[] = {-1100, -1074, -1, 0, 1, 52, 1023, 1100, 2100}; + for (size_t k = 0; k < sizeof exps / sizeof exps[0]; k++) + printf(" scalbn %d %016" PRIx64 " ldexp %016" PRIx64 "\n", exps[k], + bits(scalbn(x, exps[k])), bits(ldexp(x, exps[k]))); + } + return 0; +} diff --git a/packages/runtime/src/scr_glibc_math.test.ts b/packages/runtime/src/scr_glibc_math.test.ts new file mode 100644 index 000000000..1814ef77d --- /dev/null +++ b/packages/runtime/src/scr_glibc_math.test.ts @@ -0,0 +1,52 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const exec = promisify(execFile); + +async function hasZig(): Promise { + try { + await exec("zig", ["version"]); + return true; + } catch { + return false; + } +} + +/* The musl lane's floor, ceil, trunc, round, sqrt, fmod, scalbn, ldexp and + * frexp (scr_glibc_math.c, built for x86_64-linux-musl with zig) answer + * the same result bits as glibc's libm over the inputs + * scr_glibc_math.test.c prints. */ +test.skipIf(process.platform !== "linux" || process.arch !== "x64" || !(await hasZig()))( + "the musl lane's exactly specified libm functions answer what glibc's do", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-musl-math-")); + try { + const source = join(import.meta.dirname, "scr_glibc_math.test.c"); + const reference = join(dir, "reference"); + const musl = join(dir, "musl"); + await exec("clang", ["-std=c11", "-O2", source, "-o", reference, "-lm"]); + await exec("zig", [ + "cc", + "-target", + "x86_64-linux-musl", + "-std=c11", + "-O2", + "-DSCR_MUSL", + source, + join(import.meta.dirname, "scr_glibc_math.c"), + "-o", + musl, + ]); + const expected = (await exec(reference, [])).stdout; + const actual = (await exec(musl, [])).stdout; + expect(actual.split("\n").length).toBeGreaterThan(500); + expect(actual).toBe(expected); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, +); From 787098c08702aab32b43758437b3c130d631d354 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 05:04:49 -0500 Subject: [PATCH 37/44] Type a forward-captured island handle binding checked-dynamic A closure declared before the island array binding it reads, such as `const show = () => describe(local); const local = list();`, made the lowering predeclare the binding's slot with its declared native array type. The island lowering then retyped the binding checked-dynamic at its first use, so the closure's capture and the binding disagreed (SC9001). The predeclared slot of an island handle binding is now checked-dynamic from the start, as the binding's own declaration lowers. Test: 6897-node-island-declared-arrays gains arrows and a function declaration that capture an island array declared after them. --- .../compiler/src/frontend/lowering/lower-stmts.ts | 4 ++++ .../corpus/6897-node-island-declared-arrays/main.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 8231365b1..c2e454c23 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1123,6 +1123,10 @@ export function predeclareForwardCapture(lowerer: Lowerer, symbol: ts.Symbol): b lowerer.dynConvertible(type) ) type = DYN; + // An island handle binding holds a checked-dynamic value whatever its + // declared type says: its own declaration lowers as one, and so must + // the slot a closure captured first. + if (islandHandleBinding(lowerer, decl.name)) type = DYN; if (!TDZ_KINDS.has(type.kind) || isUnitType(type)) return false; const name = decl.name.text; const count = entry.ctx.localCounters.get(name) ?? 0; diff --git a/tests/corpus/6897-node-island-declared-arrays/main.ts b/tests/corpus/6897-node-island-declared-arrays/main.ts index 9093d97de..75fd63987 100644 --- a/tests/corpus/6897-node-island-declared-arrays/main.ts +++ b/tests/corpus/6897-node-island-declared-arrays/main.ts @@ -48,3 +48,15 @@ function captured(): string { return `${show()} ${size()} ${spread()}`; } console.log(captured()); + +// Closures declared before the island array they capture. +function capturedEarly(): string { + const show = (): string => describe(early); + const spread = (): string => [...early].join(","); + function size(): number { + return early.length; + } + const early = list(); + return `${show()} ${spread()} ${size()}`; +} +console.log(capturedEarly()); From 953293e9e40f3c21bf4e70de2e1f9821d445ef13 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 05:21:56 -0500 Subject: [PATCH 38/44] Close the socket corpus program's connection without a reset evolving-let-socket-optional-reads.cjs ended the server's side of the connection as soon as it was accepted and closed the server while the client was still ending its own side. On macOS the client could then read ECONNRESET, under Node and the compiled program alike, and each reports the unhandled 'error' event in its own form. The server now ends its side once the client's end arrives, and the server closes when the client socket has closed. The program's output is unchanged. --- tests/corpus/evolving-let-socket-optional-reads.cjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/corpus/evolving-let-socket-optional-reads.cjs b/tests/corpus/evolving-let-socket-optional-reads.cjs index 959191850..ad4170ea8 100644 --- a/tests/corpus/evolving-let-socket-optional-reads.cjs +++ b/tests/corpus/evolving-let-socket-optional-reads.cjs @@ -3,7 +3,12 @@ // its first write: undefined short-circuits, the socket answers. const net = require('net'); let c; -const server = net.createServer((s) => s.end()); +// The server ends its side after the client's, and the server closes once +// the client socket has closed, so neither side sees a reset. +const server = net.createServer((s) => { + s.resume(); + s.on('end', () => s.end()); +}); function report(label) { console.log(label, c?.remoteAddress, c?.address()?.family, c?.remotePort > 0); } @@ -14,7 +19,8 @@ server.listen(0, '127.0.0.1', () => { c = net.connect(server.address().port, '127.0.0.1', () => { report('after'); console.log('inside', c?.remoteAddress, c?.address()?.family); + c.resume(); + c.on('close', () => server.close()); c.end(); - server.close(); }); }); From a54dd09e04561af8760d5ef77ae8b933b67c35fa Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 06:14:57 -0500 Subject: [PATCH 39/44] Validate untyped listen ports and give net errors Node's system properties --- .../compiler/ambient/node-fallback/net.d.ts | 2 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-server.ts | 92 ++++++++- packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 1 + .../corpus/http-server-listen-forms.ts.json | 6 + .../http-server-listen-untyped.cjs.json | 6 + .../net-error-system-properties.cjs.json | 6 + .../net-listen-untyped-arguments.cjs.json | 6 + packages/runtime/src/scr_net.c | 191 ++++++++++++++++-- packages/runtime/src/scr_net.h | 5 + tests/corpus/http-server-listen-forms.ts | 24 +++ tests/corpus/http-server-listen-untyped.cjs | 24 +++ tests/corpus/net-error-system-properties.cjs | 52 +++++ tests/corpus/net-listen-untyped-arguments.cjs | 52 +++++ .../net-error-system-properties.cjs.json | 1 + .../net-listen-untyped-arguments.cjs.json | 1 + 17 files changed, 450 insertions(+), 23 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-forms.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-untyped.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-error-system-properties.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-listen-untyped-arguments.cjs.json create mode 100644 tests/corpus/http-server-listen-forms.ts create mode 100644 tests/corpus/http-server-listen-untyped.cjs create mode 100644 tests/corpus/net-error-system-properties.cjs create mode 100644 tests/corpus/net-listen-untyped-arguments.cjs create mode 100644 tests/harness/corpus-costs/net-error-system-properties.cjs.json create mode 100644 tests/harness/corpus-costs/net-listen-untyped-arguments.cjs.json diff --git a/packages/compiler/ambient/node-fallback/net.d.ts b/packages/compiler/ambient/node-fallback/net.d.ts index 3520518e7..3374894c1 100644 --- a/packages/compiler/ambient/node-fallback/net.d.ts +++ b/packages/compiler/ambient/node-fallback/net.d.ts @@ -178,6 +178,8 @@ declare module "net" { getConnections(callback: (error: Error | null, count: number) => void): Server; /* Node answers the server itself (`return this` chaining). */ listen(port: number, callback?: () => void): Server; + /* No port: an arbitrary unused one. */ + listen(callback?: () => void): Server; /* The positional bind address (the options form's host in argument * spelling). */ listen(port: number, host: string, callback?: () => void): Server; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 7231e9820..5be7eb0b3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1488,6 +1488,7 @@ export const LIB_FN_SYMS: Record = { // the client request forms have non-generic shapes — special-cased. "net.serverPort": "scr_net_server_port", "net.serverListening": "scr_net_server_listening", + "net.listenPortDyn": "scr_net_listen_port_dyn", "net.sockSetRef": "scr_net_sock_set_ref", "net.serverSetRef": "scr_net_server_set_ref", "net.serverOff": "scr_net_server_off", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 7ba3f7191..f1b68659f 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -1531,11 +1531,11 @@ function lowerNetServerMethodCall( return { kind: "libCall", fn, args: [receiver], type: VOID, loc }; } if (name === "listen") { - if (args.length < 1 || args.length > 3) { + if (args.length > 3) { lowerer.noLowering( `listen with ${args.length} arguments`, call, - "the supported form is listen(port[, host][, callback]) — port 0 binds an ephemeral port", + "the supported form is listen([port][, host][, callback]) — port 0 binds an ephemeral port", ); } // Node answers the server (`return this` chaining — `const s = @@ -1549,6 +1549,90 @@ function lowerNetServerMethodCall( return receiverReturningCall(lowerer, fn, callArgs, NETSERVER_T, loc); }; const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + // listen() and listen(callback): Node binds an arbitrary unused port + // (normalizeArgs takes a function first argument as the callback). + if (args.length === 0) return listenResult("net.listen", [receiver, numLit(0, loc)]); + const first = lowerer.mapTypeOf(lowerer.typeOf(args[0]!)); + // An untyped first argument: a checked-dynamic value, a JavaScript value + // read into an untyped slot, or a checker `any`. + const untyped = + args.length > 0 && + (first?.kind === "dyn" || + first?.kind === "jsval" || + (lowerer.typeOf(args[0]!).flags & ts.TypeFlags.Any) !== 0); + if (args.length === 1 && first?.kind === "func") { + const { cb } = lowerCallbackArg( + lowerer, + args[0]!, + "listen callbacks", + 0, + () => false, + "use ()", + [], + ); + return listenResult("net.listenCb", [receiver, numLit(0, loc), cb]); + } + // An untyped first argument takes its port from net.listenPortDyn, + // Node's normalizeArgs and validatePort at run time: 0 for a function, + // undefined or null, the number of a port number or numeric string, + // ERR_SOCKET_BAD_PORT for a bad port, and the fence for an options + // object or a pipe path, which have no lowering from untyped values. + const portOfUntyped = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "net.listenPortDyn", + args: [ + value, + ladderFenceExpr( + lowerer, + "listen with a runtime options object or pipe path", + args[0]!, + "pass the port as a number, the path as a string, or the options as an object literal", + ), + ], + type: F64, + loc, + }); + if (args.length === 1 && untyped) { + // An untyped single argument: a function is the callback on an + // arbitrary port; any other value is the port. + const server = lowerer.declareHiddenLocal("%listenServer", NETSERVER_T); + const arg = lowerer.declareHiddenLocal("%listenArg", DYN); + const serverRef = (): IrExpr => varRef(server.id, NETSERVER_T, loc); + const argRef = (): IrExpr => varRef(arg.id, DYN, loc); + const listenStmt = (fn: IrLibFn, callArgs: IrExpr[]): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "libCall", fn, args: callArgs, type: VOID, loc }, + loc, + }); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: server.id, init: receiver, loc }, + { + kind: "varDecl", + localId: arg.id, + init: lowerer.coerceToExpected(lowerer.lowerExpr(args[0]!), DYN), + loc, + }, + { + kind: "if", + cond: { kind: "dynTest", test: "function", value: argRef(), type: BOOL, loc }, + then: [ + listenStmt("net.listenCb", [ + serverRef(), + numLit(0, loc), + { kind: "dynCheck", value: argRef(), type: funcOf([], VOID), loc }, + ]), + ], + else_: [listenStmt("net.listen", [serverRef(), portOfUntyped(argRef())])], + loc, + }, + ], + result: serverRef(), + type: NETSERVER_T, + loc, + }; + } // The options-object form — listen({ port, host?, ipv6Only?, reusePort? }[, cb]), // the portless listenOnProxyInterface shape: host binds that ONE // address (an IP literal — the runtime has no resolver here; absent @@ -1762,7 +1846,9 @@ function lowerNetServerMethodCall( ); return listenResult("net.listenPathCb", [receiver, path, cb]); } - const port = lowerer.lowerExprExpecting(args[0]!, F64); + const port = untyped + ? portOfUntyped(lowerer.coerceToExpected(lowerer.lowerExpr(args[0]!), DYN)) + : lowerer.lowerExprExpecting(args[0]!, F64); // The optional middle host — listen(port, '127.0.0.1'[, cb]): a // string-typed second argument is the bind address (the net.connect // disambiguation), routed through the options-form entry. diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 3ee94312a..7c298e993 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -992,6 +992,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "net.connectCb", "net.connectPath", "net.connectPathCb", + // listen's port from a checked-dynamic first argument: ERR_SOCKET_BAD_PORT, + // or the fence for forms without a lowering. + "net.listenPortDyn", // resetAndDestroy on a TLS socket throws ERR_INVALID_HANDLE_TYPE. "net.sockResetAndDestroy", // SocketAddress's option checks and parse()'s input check. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 802d190a5..9a7d304ee 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1711,6 +1711,7 @@ function buildLibFnSignatures() { * compiler-rendered fence — ALWAYS THROWS (the error.nodeThrow * polymorphic-result carve-out). May-throw seed. */ "net.connectOptsChk": { argTypes: [DYN, STRING], result: VOID }, + "net.listenPortDyn": { argTypes: [DYN, STRING], result: F64 }, "net.connectCb": { argTypes: [F64, STRING, { kind: "func", params: [], ret: VOID }], result: NETSOCKET_T, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-forms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-forms.ts.json new file mode 100644 index 000000000..5e1906f01 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-forms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/http-server-listen-forms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-untyped.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-untyped.cjs.json new file mode 100644 index 000000000..ddb986179 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-server-listen-untyped.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/http-server-listen-untyped.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-error-system-properties.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-error-system-properties.cjs.json new file mode 100644 index 000000000..3d96f5745 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-error-system-properties.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-error-system-properties.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-listen-untyped-arguments.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-listen-untyped-arguments.cjs.json new file mode 100644 index 000000000..9d6dece2f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/net-listen-untyped-arguments.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/net-listen-untyped-arguments.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index a31cd4688..658e4c0d3 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -1,6 +1,7 @@ #include "scr_platform.h" #include "scr_runtime.h" +#include "scr_system_errors.h" #include #include @@ -413,6 +414,95 @@ void scr_net_fire_err_this(ScrNetLs *l, ScrStr *msg, void *self, ScrDynHandleTag void scr_net_fire_err(ScrNetLs *l, ScrStr *msg) { scr_net_fire_err_this(l, msg, NULL, 0); } +/* The libuv errno of an error code name (Node's err.errno), 0 for a name + * outside libuv's map. */ +static double scr_net_uv_errno(const char *code, size_t len) { +#define SCR_NET_UV_ERRNO(name, text) \ + if (len == sizeof #name - 1 && memcmp(code, #name, len) == 0) return SCR_NODE_UV__##name; + SCR_NODE_UV_ERRNO_MAP(SCR_NET_UV_ERRNO) +#undef SCR_NET_UV_ERRNO + return 0; +} + +/* The libuv message of an error code name, or NULL. */ +static const char *scr_net_uv_text(const char *code, size_t len) { +#define SCR_NET_UV_TEXT(name, text) \ + if (len == sizeof #name - 1 && memcmp(code, #name, len) == 0) return text; + SCR_NODE_UV_ERRNO_MAP(SCR_NET_UV_TEXT) +#undef SCR_NET_UV_TEXT + return NULL; +} + +/* Node's system error for a net failure message this unit writes: + * " [ ]" (errnoException, exceptionWithHostPort) or + * " : [ ]" (uvExceptionWithHostPort). + * The error carries the code, the libuv errno and the syscall; for + * connect, listen and bind, the detail is the address, as "
:" + * with a port, and a listen on a pipe path has port -1. NULL for any other + * message; +1 otherwise. */ +static ScrError *scr_net_system_error(ScrStr *msg) { + const char *data = msg->data; + const char *end = data + msg->len; + const char *space = memchr(data, ' ', msg->len); + if (space == NULL || space == data) return NULL; + size_t syscall_len = (size_t)(space - data); + const char *code = space + 1; + const char *code_end = code; + while (code_end < end && *code_end != ' ' && *code_end != ':') code_end++; + size_t code_len = (size_t)(code_end - code); + if (code_len < 2 || code[0] != 'E') return NULL; + for (size_t i = 0; i < code_len; i++) { + char c = code[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) return NULL; + } + double uv = scr_net_uv_errno(code, code_len); + if (uv == 0) return NULL; + const char *detail = code_end; + if (detail < end && *detail == ':') { + /* the libuv form: skip ": " */ + const char *text = scr_net_uv_text(code, code_len); + size_t text_len = text != NULL ? strlen(text) : 0; + if (text == NULL || (size_t)(end - detail) < 2 + text_len || + memcmp(detail + 2, text, text_len) != 0) + return NULL; + detail += 2 + text_len; + } + if (detail < end && *detail == ' ') detail++; + ScrError *e = scr_error_new(SCR_ERR_ERROR, msg); + char code_buf[32]; + size_t n = code_len < sizeof code_buf - 1 ? code_len : sizeof code_buf - 1; + memcpy(code_buf, code, n); + code_buf[n] = '\0'; + scr_error_set_code(e, code_buf); + e->system_errno = uv; + e->system_call = scr_str_new(data, syscall_len); + bool with_address = (syscall_len == 7 && memcmp(data, "connect", 7) == 0) || + (syscall_len == 6 && memcmp(data, "listen", 6) == 0) || + (syscall_len == 4 && memcmp(data, "bind", 4) == 0); + if (with_address && detail < end) { + const char *colon = NULL; + for (const char *q = detail; q < end; q++) + if (*q == ':') colon = q; + bool digits = colon != NULL && colon + 1 < end; + for (const char *q = colon != NULL ? colon + 1 : end; q < end; q++) + if (*q < '0' || *q > '9') digits = false; + const char *address_end = digits ? colon : end; + ScrStr *address = scr_str_new(detail, (size_t)(address_end - detail)); + scr_error_set_prop(e, "address", 7, scr_dyn_new_str(address)); + /* a pipe listen: Node's listenInCluster passes port -1 with the path */ + bool pipe_listen = false; + if (!digits && syscall_len == 6) { + unsigned char ip[16]; + pipe_listen = inet_pton(AF_INET, address->data, ip) != 1 && + inet_pton(AF_INET6, address->data, ip) != 1; + } + scr_str_release(address); + if (digits) scr_error_set_prop(e, "port", 4, scr_dyn_new_num(strtod(colon + 1, NULL))); + else if (pipe_listen) scr_error_set_prop(e, "port", 4, scr_dyn_new_num(-1)); + } + return e; +} + /* ── the handles ─────────────────────────────────────────────────────── */ enum { SCR_NET_K_SERVER = 1, SCR_NET_K_SOCKET = 2 }; @@ -3075,7 +3165,7 @@ void scr_net_connect_opts_chk(const ScrDyn *opts, const ScrStr *fence) { char detail[64], msg[160]; const char *d = scr_dyn_specific_type(port, detail, sizeof detail); int len = snprintf(msg, sizeof msg, - "options.port should be >= 0 and < 65536. Received %s", d); + "Port should be >= 0 and < 65536. Received %s.", d); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_SOCKET_BAD_PORT"); return; } @@ -3098,6 +3188,60 @@ void scr_net_connect_opts_chk(const ScrDyn *opts, const ScrStr *fence) { scr_throw_lowering_fence(fence); } +/* The port of listen(arg[, ...]) for a checked-dynamic first argument, as + * Node's Server.prototype.listen normalizes it: a function (listen(cb)), + * undefined and null bind an ephemeral port (0); a number, or a string + * whose Number() is >= 0, passes validatePort and answers its integer. + * Other values take forms that have no lowering here (an options object, a + * pipe path, and the ERR_INVALID_ARG_VALUE kinds) and throw the + * compiler-rendered fence. */ +/* Node's validatePort over listen's port value: 1 with *out set for a + * function, undefined or null (0) or a valid port; 0 with ERR_SOCKET_BAD_PORT + * pending for a bad port; -1 for a value that is no port form here (an + * options object, a pipe path, and the ERR_INVALID_ARG_VALUE kinds). */ +static int scr_net_listen_port_check(const ScrDyn *v, double *out) { + *out = 0; + if (v == NULL || v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL || + v->kind == SCR_DYN_FUNC) + return 1; + double n; + bool ok; + if (v->kind == SCR_DYN_NUM) { + n = v->v.num; + ok = n >= 0 && n <= 65535 && trunc(n) == n; + } else if (v->kind == SCR_DYN_STR) { + ScrStr *ps = scr_str_retain(v->v.str); + n = scr_string_to_number(ps); + if (!(n >= 0)) { /* Node's isPipeName: a path */ + scr_str_release(ps); + return -1; + } + ScrStr *trimmed = scr_str_trim(ps); + ok = trimmed->len > 0 && n <= 65535 && trunc(n) == n; + scr_str_release(trimmed); + scr_str_release(ps); + } else { + return -1; + } + if (!ok) { + char detail[64], msg[160]; + const char *d = scr_dyn_specific_type(v, detail, sizeof detail); + int len = snprintf(msg, sizeof msg, + "options.port should be >= 0 and < 65536. Received %s.", d); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, len < 0 ? 0 : (size_t)len, + "ERR_SOCKET_BAD_PORT"); + return 0; + } + *out = n; + return 1; +} + +double scr_net_listen_port_dyn(const ScrDyn *v, const ScrStr *fence) { + double port; + if (scr_net_listen_port_check(v, &port) < 0) scr_throw_lowering_fence(fence); + return port; +} + /* A dial the caller starts LATER (the http agent's maxSockets queue): * the socket exists, registers, and buffers writes as "connecting" — * scr_net_sock_dial_start runs the actual dial when a slot frees. */ @@ -3290,6 +3434,8 @@ void scr_net_listen_path(ScrNetServer *s, ScrStr *path /*borrowed*/, err = errno; } #endif + /* libuv's uv_pipe_bind reports ENOENT as EACCES, as Windows does */ + if (err == ENOENT) err = EACCES; ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, "listen "); @@ -6060,8 +6206,10 @@ static bool scr_net_sweep_sock(ScrNetSocket *sock) { if (sock->pending_code != NULL) { coded = scr_error_new(SCR_ERR_ERROR, msg); coded->code = scr_str_new(sock->pending_code, strlen(sock->pending_code)); - scr_event_error_payload = coded; + } else { + coded = scr_net_system_error(msg); } + if (coded != NULL) scr_event_error_payload = coded; scr_net_fire_err_this(&sock->err_ls, msg, sock, SCR_DYNH_NET_SOCKET); scr_event_error_payload = saved; if (coded != NULL) scr_error_release(coded); @@ -6126,7 +6274,12 @@ static void scr_net_sweep(void) { ScrStr *msg = srv->pending_err; srv->pending_err = NULL; ScrAlsCtx *prev = scr_als_dispatch_enter(srv->listen_ctx); + ScrError *system = scr_net_system_error(msg); + ScrError *saved = scr_event_error_payload; + if (system != NULL) scr_event_error_payload = system; scr_net_fire_err_this(&srv->err_ls, msg, srv, SCR_DYNH_NET_SERVER); + scr_event_error_payload = saved; + if (system != NULL) scr_error_release(system); scr_als_dispatch_leave(prev); scr_str_release(msg); /* A failed listen SETTLES the handle: listeners drop (an error @@ -6875,29 +7028,27 @@ static ScrDyn *scr_net_dynh_srv_invoke(void *h, ScrDyn *self, const char *method return scr_dyn_retain(self); } if (strcmp(method, "listen") == 0) { - /* listen([port][, host][, cb]) — the static lowering's shapes over - * dyn arguments; the options-object form throws the 'not supported yet' + /* listen([port][, host][, backlog][, cb]) and listen(path[, cb]) over + * dyn arguments, as Node's normalizeArgs reads them: the last argument + * is the callback when it is a function, a first argument that is a + * function, undefined or null binds an unused port, a string whose + * Number() is not >= 0 is a pipe path, and a string second argument is + * the host. The options-object form throws the 'not supported yet' * Error. */ + const ScrDyn *first = argc > 0 ? args[0] : NULL; + const ScrDyn *cb = argc > 0 && args[argc - 1]->kind == SCR_DYN_FUNC ? args[argc - 1] : NULL; double port = 0; - ScrStr *host = NULL; - const ScrDyn *cb = NULL; - size_t i = 0; - if (i < argc && args[i]->kind == SCR_DYN_NUM) { port = args[i]->v.num; i++; } - else if (i < argc && args[i]->kind == SCR_DYN_STR && args[i]->v.str->len > 0 && - args[i]->v.str->data[0] >= '0' && args[i]->v.str->data[0] <= '9') { - /* Node coerces a numeric port string */ - port = strtod(args[i]->v.str->data, NULL); - i++; - } else if (i < argc && args[i]->kind == SCR_DYN_OBJ) { + int form = scr_net_listen_port_check(first, &port); + if (form == 0) return NULL; + if (form < 0 && first->kind == SCR_DYN_STR) { + scr_net_listen_path(s, first->v.str, cb ? scr_dyn_listener_closure0(cb) : NULL); + return scr_dyn_retain(self); + } + if (form < 0) { scr_net_dynh_srv_unsupported("listen", "the options-object form on a dynamic value is not modeled (use listen(port[, host][, cb]))"); return NULL; } - if (i < argc && args[i]->kind == SCR_DYN_STR) { host = args[i]->v.str; i++; } - if (i < argc && args[i]->kind == SCR_DYN_FUNC) { cb = args[i]; i++; } - if (i < argc && args[i]->kind != SCR_DYN_UNDEF && args[i]->kind != SCR_DYN_NULL) { - scr_net_dynh_srv_unsupported("listen", "unrecognized arguments (the modeled shape is listen(port[, host][, cb]))"); - return NULL; - } + ScrStr *host = argc > 1 && args[1]->kind == SCR_DYN_STR ? args[1]->v.str : NULL; ScrClosure *clo = cb ? scr_dyn_listener_closure0(cb) : NULL; if (host) scr_net_listen_opts(s, port, host, false, clo); else scr_net_listen(s, port, clo); diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index f22943717..f088f6c35 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -229,6 +229,11 @@ ScrNetSocket *scr_net_connect_attempt(double port, ScrStr *host /*borrowed*/, co * autoSelectFamily, attempt budget), then the compiler-rendered fence — * always leaves an exception pending. Borrowed. */ void scr_net_connect_opts_chk(const ScrDyn *opts, const ScrStr *fence); +/* The port of listen(arg[, ...]) for a checked-dynamic first argument + * (Node's normalizeArgs and validatePort): 0 for a function, undefined or + * null; ERR_SOCKET_BAD_PORT pending for a bad port; the fence pending for + * an options object or a pipe path. Borrowed. */ +double scr_net_listen_port_dyn(const ScrDyn *v, const ScrStr *fence); /* connect with a caller lookup (net.connect({ ..., lookup })): invokes * lookup(hostname, options, answer-closure) synchronously; answer_fn is * the emitted per-shape thunk that decodes the answer down to diff --git a/tests/corpus/http-server-listen-forms.ts b/tests/corpus/http-server-listen-forms.ts new file mode 100644 index 000000000..ae93f5ea8 --- /dev/null +++ b/tests/corpus/http-server-listen-forms.ts @@ -0,0 +1,24 @@ +// server.listen() and server.listen(callback) bind an arbitrary unused port, +// as Node's normalizeArgs takes a function first argument as the callback. +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; + +const first = http.createServer((req, res) => res.end("first")); +first.listen(() => { + const port = (first.address() as AddressInfo).port; + console.log("callback form port > 0", port > 0); + http.get({ host: "127.0.0.1", port }, (res) => { + res.setEncoding("utf8"); + res.on("data", (chunk: string) => console.log("body", chunk)); + res.on("end", () => { + first.close(); + const second = http.createServer((req2, res2) => res2.end("second")); + second.on("listening", () => { + const port2 = (second.address() as AddressInfo).port; + console.log("no-argument form port > 0", port2 > 0, port2 !== port); + second.close(); + }); + second.listen(); + }); + }); +}); diff --git a/tests/corpus/http-server-listen-untyped.cjs b/tests/corpus/http-server-listen-untyped.cjs new file mode 100644 index 000000000..d927c6961 --- /dev/null +++ b/tests/corpus/http-server-listen-untyped.cjs @@ -0,0 +1,24 @@ +// server.listen(value) with an untyped value: a function is the 'listening' +// callback on an arbitrary unused port, as in Node; a number is the port. +'use strict'; +const http = require('http'); + +function wrap(fn) { + return function (...args) { + return fn.apply(this, args); + }; +} + +const server = http.createServer((req, res) => res.end('ok')); +server.listen(wrap(() => { + const port = server.address().port; + console.log('port > 0', port > 0); + server.close(() => { + const other = http.createServer(); + const chosen = [0][0]; + other.listen(chosen, wrap(() => { + console.log('numeric port > 0', other.address().port > 0); + other.close(); + })); + }); +})); diff --git a/tests/corpus/net-error-system-properties.cjs b/tests/corpus/net-error-system-properties.cjs new file mode 100644 index 000000000..12bd69731 --- /dev/null +++ b/tests/corpus/net-error-system-properties.cjs @@ -0,0 +1,52 @@ +// net 'error' events carry Node's system error properties: errno, code +// and syscall, and for listen and connect the address and port (a Unix +// socket's path, with port -1 for listen). A reset connection's read error +// carries errno, code and syscall only. +"use strict"; +const net = require("net"); + +function describe(label, err, port) { + const hide = (text) => (port === undefined ? text : text.replace(String(port), "")); + console.log( + label, + JSON.stringify({ + message: hide(err.message), + code: err.code, + errno: err.errno, + syscall: err.syscall, + address: err.address, + port: port !== undefined && err.port === port ? "" : err.port, + }), + ); +} + +const taken = net.createServer(); +taken.listen(0, "127.0.0.1", () => { + const port = taken.address().port; + const second = net.createServer(); + second.on("error", (err) => { + describe("listen in use", err, port); + const unix = net.createServer(); + unix.on("error", (err2) => { + describe("listen path", err2); + const client = net.connect(1, "127.0.0.1"); + client.on("error", (err3) => { + describe("connect refused", err3); + const resetting = net.createServer((socket) => { + socket.on("error", (err4) => { + describe("read reset", err4); + resetting.close(); + taken.close(); + }); + }); + resetting.listen(0, "127.0.0.1", () => { + const victim = net.connect(resetting.address().port, "127.0.0.1", () => { + setTimeout(() => victim.resetAndDestroy(), 20); + }); + }); + }); + }); + unix.listen("/nonexistent-dir/sock"); + }); + second.listen(port, "127.0.0.1"); +}); diff --git a/tests/corpus/net-listen-untyped-arguments.cjs b/tests/corpus/net-listen-untyped-arguments.cjs new file mode 100644 index 000000000..07e3bffa3 --- /dev/null +++ b/tests/corpus/net-listen-untyped-arguments.cjs @@ -0,0 +1,52 @@ +// server.listen with untyped arguments follows Node's normalizeArgs and +// validatePort: a function is the 'listening' callback on an unused port, +// undefined and null take an unused port, a numeric string is a port, and +// a port out of range or not an integer throws ERR_SOCKET_BAD_PORT, as a +// bad port in a connect options object checked at run time does. +"use strict"; +const net = require("net"); + +// Values read from JSON are untyped, as a test helper's results are. +const raw = JSON.parse( + '{"none":null,"zero":0,"text":"0","bad":[-1,65536,1.5,""," ","70000"]}', +); +const wrap = (fn) => (...args) => fn(...args); +const forms = [ + ["callback only", (server, done) => server.listen(wrap(done))], + ["undefined port", (server, done) => server.listen(raw.missing, done)], + ["null port", (server, done) => server.listen(raw.none, done)], + ["numeric string", (server, done) => server.listen(raw.text, done)], + ["host and callback", (server, done) => server.listen(raw.zero, "127.0.0.1", done)], +]; + +function next(i) { + if (i === forms.length) { + for (const bad of raw.bad) { + const server = net.createServer(); + try { + server.listen(bad); + console.log(JSON.stringify(bad), "listened"); + server.close(); + } catch (err) { + console.log(JSON.stringify(bad), err.name, err.code, err.message); + } + } + // A connect options object with a computed key is checked at run time. + const key = ["po", "rt"].join(""); + try { + net.connect({ [key]: -1 }).destroy(); + console.log("connect", "connected"); + } catch (err) { + console.log("connect", err.name, err.code, err.message); + } + return; + } + const [label, listen] = forms[i]; + const server = net.createServer(); + listen(server, () => { + const port = server.address().port; + console.log(label, typeof port, port > 0); + server.close(() => next(i + 1)); + }); +} +next(0); diff --git a/tests/harness/corpus-costs/net-error-system-properties.cjs.json b/tests/harness/corpus-costs/net-error-system-properties.cjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/net-error-system-properties.cjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } diff --git a/tests/harness/corpus-costs/net-listen-untyped-arguments.cjs.json b/tests/harness/corpus-costs/net-listen-untyped-arguments.cjs.json new file mode 100644 index 000000000..9622c5beb --- /dev/null +++ b/tests/harness/corpus-costs/net-listen-untyped-arguments.cjs.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 3000 } From f1e431188180764b940d763145721b032bae1e67 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 06:16:58 -0500 Subject: [PATCH 40/44] Check the request path and method before a socket is opened http.request runs the ClientRequest constructor's path and method checks before it calls a createConnection option, takes a pooled socket or dials a socketPath, as Node does. Before, the createConnection callback had already run, and a socketPath server had seen a connection, when the check threw ERR_UNESCAPED_CHARACTERS or ERR_INVALID_HTTP_TOKEN. --- .../http-request-checks-before-socket.ts.json | 6 ++ packages/runtime/src/scr_http.c | 71 ++++++++++++------- .../http-request-checks-before-socket.ts | 59 +++++++++++++++ 3 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-checks-before-socket.ts.json create mode 100644 tests/corpus/http-request-checks-before-socket.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-checks-before-socket.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-checks-before-socket.ts.json new file mode 100644 index 000000000..b5fdba7ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-request-checks-before-socket.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/http-request-checks-before-socket.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 68e08a069..bbc0a71b3 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -5176,23 +5176,13 @@ static bool scr_http_request_path_valid(const ScrStr *path /*borrowed*/) { return true; } -static ScrHttpClientReq *scr_http_request_impl(ScrStr *host /*borrowed*/, double port, - ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, - double timeout_ms, ScrArr *header_pairs /*borrowed*/, - bool auto_end, ScrClosure *cb /*moves, nullable*/, - ScrHttpRespFn fn, int default_port, - void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, - ScrNetSocket *presock /*moves, nullable*/) { - /* Node's ClientRequest constructor validates the path, then the method - * token (RFC 9110 tchar, at least one): the catchable - * ERR_INVALID_HTTP_TOKEN TypeError with Node's exact message, the raw - * method text inside the quotes. Moved-in arguments release — the - * request never exists. */ - if (!scr_http_request_path_valid(path)) { - if (cb) scr_closure_release(cb); - if (presock) scr_net_sock_release(presock); - return NULL; - } +/* The ClientRequest constructor's checks that come before any socket + * exists, in Node's order: the path (ERR_UNESCAPED_CHARACTERS), then the + * method token (RFC 9110 tchar, at least one; ERR_INVALID_HTTP_TOKEN with + * the raw method text inside the quotes). False with the exception + * pending. */ +static bool scr_http_request_checks(const ScrStr *path /*borrowed*/, const ScrStr *method /*borrowed*/) { + if (!scr_http_request_path_valid(path)) return false; bool method_ok = method->len > 0; for (size_t i = 0; i < method->len && method_ok; i++) { unsigned char ch = (unsigned char)method->data[i]; @@ -5200,17 +5190,30 @@ static ScrHttpClientReq *scr_http_request_impl(ScrStr *host /*borrowed*/, double (ch >= '0' && ch <= '9') || (ch != 0 && strchr("!#$%&'*+-.^_`|~", ch) != NULL); } - if (!method_ok) { + if (method_ok) return true; + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Method must be a valid HTTP token [\""); + scr_jb_write(&b, method->data, method->len); + scr_jb_puts(&b, "\"]"); + ScrStr *msg = scr_jb_finish(&b); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg->data, msg->len, "ERR_INVALID_HTTP_TOKEN"); + scr_str_release(msg); + return false; +} + +static ScrHttpClientReq *scr_http_request_impl(ScrStr *host /*borrowed*/, double port, + ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, + double timeout_ms, ScrArr *header_pairs /*borrowed*/, + bool auto_end, ScrClosure *cb /*moves, nullable*/, + ScrHttpRespFn fn, int default_port, + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrNetSocket *presock /*moves, nullable*/) { + /* Node's ClientRequest constructor checks; moved-in arguments release + * when they fail — the request never exists. */ + if (!scr_http_request_checks(path, method)) { if (cb) scr_closure_release(cb); if (presock) scr_net_sock_release(presock); - ScrJsonBuf b; - scr_jb_init(&b); - scr_jb_puts(&b, "Method must be a valid HTTP token [\""); - scr_jb_write(&b, method->data, method->len); - scr_jb_puts(&b, "\"]"); - ScrStr *msg = scr_jb_finish(&b); - scr_throw_error_msg_code(SCR_ERR_TYPE, msg->data, msg->len, "ERR_INVALID_HTTP_TOKEN"); - scr_str_release(msg); return NULL; } scr_http_install(); @@ -5311,6 +5314,12 @@ ScrHttpClientReq *scr_http_request_conn(ScrClosure *conn_cb /*moves*/, ScrStr *p ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { + /* Node checks the path and method before it calls createConnection */ + if (!scr_http_request_checks(path, method)) { + scr_closure_release(conn_cb); + if (cb != NULL) scr_closure_release(cb); + return NULL; + } ScrNetSocket *sock = ((ScrNetSocket *(*)(ScrClosure *))conn_cb->fn)(conn_cb); /* +1 result */ scr_closure_release(conn_cb); if (scr_exc_pending() || sock == NULL) { @@ -5832,6 +5841,11 @@ static ScrHttpClientReq *scr_http_request_pooled(ScrHttpAgent *ag, ScrStr *host void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, ScrStr *pool_key /*borrowed*/, ScrStr *socket_path /*borrowed*/) { + /* the constructor's checks come before the agent takes or dials a socket */ + if (!scr_http_request_checks(path, method)) { + if (cb) scr_closure_release(cb); + return NULL; + } double p = port >= 0 ? port : ag->default_port > 0 ? ag->default_port : (double)default_port; @@ -5892,6 +5906,11 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po /* agent: false — Node's one-shot Agent: exactly this client's model, * plus its Connection: close request header (unless the caller set one) */ if (agent->kind == SCR_DYN_BOOL && !agent->v.b) { + /* the constructor's checks come before a Unix socket is dialed */ + if (!scr_http_request_checks(path, method)) { + if (cb) scr_closure_release(cb); + return NULL; + } double p = port >= 0 ? port : (double)default_port; bool has_conn = false; size_t npairs = (size_t)scr_arr_len(header_pairs); diff --git a/tests/corpus/http-request-checks-before-socket.ts b/tests/corpus/http-request-checks-before-socket.ts new file mode 100644 index 000000000..868871db1 --- /dev/null +++ b/tests/corpus/http-request-checks-before-socket.ts @@ -0,0 +1,59 @@ +// http.request checks the path and the method before it opens a socket, as +// Node's ClientRequest constructor does: createConnection is not called and +// a socketPath server sees no connection when either check throws. +import * as http from "node:http"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; + +function attempt(label: string, fn: () => void): void { + try { + fn(); + console.log(label, "no throw"); + } catch (error) { + const e = error as NodeJS.ErrnoException; + console.log(label, e.name, e.code); + } +} + +let connections = 0; +const socketPath = path.join(os.tmpdir(), `scriptc-checks-${process.pid}.sock`); +const server = net.createServer((socket) => { + connections++; + socket.destroy(); +}); + +server.listen(socketPath, () => { + attempt("createConnection path", () => { + http.request({ + path: "/a b", + createConnection: () => { + console.log("createConnection called"); + return net.connect(socketPath); + }, + }); + }); + attempt("createConnection method", () => { + http.request({ + path: "/", + method: "BAD METHOD", + createConnection: () => { + console.log("createConnection called"); + return net.connect(socketPath); + }, + }); + }); + attempt("socketPath path", () => { + http.request({ socketPath, path: "/a\tb" }); + }); + attempt("socketPath agent false path", () => { + http.request({ socketPath, path: "/a b", agent: false }); + }); + attempt("socketPath method", () => { + http.request({ socketPath, path: "/", method: "A(B" }); + }); + setTimeout(() => { + console.log("connections", connections); + server.close(() => console.log("closed")); + }, 200); +}); From 4610fe32be43fdcfdaec9bafb880994813d01a57 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 06:30:40 -0500 Subject: [PATCH 41/44] Read SharedArrayBuffer, DataView, BigInt64Array, BigUint64Array, Proxy and Intl as values SharedArrayBuffer, DataView, BigInt64Array, BigUint64Array and Proxy read as values are interned constructor values with Node's name and length. Calling one throws Node's TypeError "Constructor X requires 'new'". instanceof through a stored value answers as Node does: SharedArrayBuffer and DataView test the value's kind, BigInt64Array and BigUint64Array answer false (neither is constructed), and Proxy answers false for a primitive and throws Node's TypeError for an object. Intl read as a value is a namespace object with Node's 12 members in Node's order. Its members are function values that throw the SC1090 fence when called; direct calls such as new Intl.NumberFormat("en-US") keep their lowerings. TypeScript sources read the same values into any and unknown slots, so the diagnostics program uses Atomics for a builtin without a value. --- .../frontend/lowering/builtin-namespaces.ts | 21 ++++++++++-- .../lowering/expressions/property-access.ts | 10 +++++- .../builtin-values-proxy-intl-views.cjs.json | 6 ++++ packages/runtime/src/scr_dyn_invoke.c | 32 ++++++++++++++---- packages/runtime/src/scr_json.c | 16 +++++++++ .../builtin-values-proxy-intl-views.cjs | 33 +++++++++++++++++++ tests/corpus/builtin-values-untyped-slots.ts | 11 +++++++ .../builtin-values-without-lowering.ts | 2 +- .../builtin-values-without-lowering.ts.txt | 14 ++++---- .../builtin-values-proxy-intl-views.cjs.json | 1 + 10 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-proxy-intl-views.cjs.json create mode 100644 tests/corpus/builtin-values-proxy-intl-views.cjs create mode 100644 tests/harness/corpus-costs/builtin-values-proxy-intl-views.cjs.json diff --git a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts index 07c6bda5b..991e40657 100644 --- a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts @@ -1,4 +1,4 @@ -/** The standard namespace objects (`Math`, `JSON`, `Reflect`) read as +/** The standard namespace objects (`Math`, `JSON`, `Reflect`, `Intl`) read as * JavaScript values. Each is one object per program, built on first use, * with Node's own properties in Node's order: the functions are writable, * configurable and non-enumerable, the Math constants are read-only and @@ -106,6 +106,21 @@ function namespaceMembers(name: string): readonly NamespaceMember[] | null { { key: "set", length: 3 }, { key: "setPrototypeOf", length: 2 }, ]; + case "Intl": + return [ + { key: "getCanonicalLocales", length: 1 }, + { key: "supportedValuesOf", length: 1 }, + { key: "DateTimeFormat", length: 0 }, + { key: "NumberFormat", length: 0 }, + { key: "Collator", length: 0 }, + { key: "PluralRules", length: 0 }, + { key: "RelativeTimeFormat", length: 0 }, + { key: "ListFormat", length: 0 }, + { key: "Locale", length: 1 }, + { key: "DisplayNames", length: 2 }, + { key: "Segmenter", length: 0 }, + { key: "DurationFormat", length: 0 }, + ]; default: return null; } @@ -270,7 +285,9 @@ function functionValue(lowerer: Lowerer, canonical: string, key: string, loc: Sr ? mathMemberValue(lowerer, key, loc) : canonical === "JSON" ? jsonMemberValue(lowerer, key, loc) - : reflectMemberValue(lowerer, key, loc); + : canonical === "Reflect" + ? reflectMemberValue(lowerer, key, loc) + : null; if (value !== null) return value.type.kind === "dyn" ? value diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3b617c9fe..9daeaf83a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4993,7 +4993,10 @@ const ERROR_CONSTRUCTOR_VALUES: ReadonlySet = new Set([ * constructor, and dynamic values answer `.constructor` with the same one, * so `o.constructor === Object` and `err.constructor === TypeError` are * exact identity tests, and `err.constructor.name` is the constructor's - * name. */ + * name. SharedArrayBuffer, DataView, BigInt64Array, BigUint64Array and + * Proxy have values with Node's name, length and instanceof answers; + * calling one throws Node's TypeError, and constructing through the value + * is fenced. */ const GLOBAL_CONSTRUCTOR_VALUES: ReadonlySet = new Set([ "Object", "Function", @@ -5005,6 +5008,11 @@ const GLOBAL_CONSTRUCTOR_VALUES: ReadonlySet = new Set([ "WeakMap", "WeakSet", "FinalizationRegistry", + "SharedArrayBuffer", + "DataView", + "BigInt64Array", + "BigUint64Array", + "Proxy", ...ERROR_CONSTRUCTOR_VALUES, ]); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-proxy-intl-views.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-proxy-intl-views.cjs.json new file mode 100644 index 000000000..c4a1cf629 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-proxy-intl-views.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/builtin-values-proxy-intl-views.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 0279ccab1..8ac5bfc2d 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -1959,7 +1959,9 @@ ScrDyn *scr_dyn_builtin_method(const ScrStr *prototype, const ScrStr *method) { * answer `.constructor` on the matching dynamic values, so * `o.constructor === Object` and `a.constructor === b.constructor` are * exact identity tests. `Object(v)` works through the stored value; calling - * the others through it is fenced. */ + * the others through it is fenced. SharedArrayBuffer, DataView, + * BigInt64Array, BigUint64Array and Proxy are interned the same way; + * calling one throws Node's "Constructor X requires 'new'" TypeError. */ /* String constants live in named arrays in their own section rather than * in the unit's merged string pool (the explicit section also keeps the * compiler from folding the unit's existing literals into them), so @@ -2012,11 +2014,23 @@ static const char dyn_s38[] SCR_DYN_COMPAT_STR = "structuredClone"; static const char dyn_s39[] SCR_DYN_COMPAT_STR = "The \"input\" argument must be specified"; static const char dyn_s40[] SCR_DYN_COMPAT_STR = "The \"The value argument must be specified\" argument must be specified"; static const char dyn_s41[] SCR_DYN_COMPAT_STR = "ERR_MISSING_ARGS"; +static const char dyn_s42[] SCR_DYN_COMPAT_STR = "SharedArrayBuffer"; +static const char dyn_s43[] SCR_DYN_COMPAT_STR = "DataView"; +static const char dyn_s44[] SCR_DYN_COMPAT_STR = "BigInt64Array"; +static const char dyn_s45[] SCR_DYN_COMPAT_STR = "BigUint64Array"; +static const char dyn_s46[] SCR_DYN_COMPAT_STR = "Proxy"; +static const char dyn_s47[] SCR_DYN_COMPAT_STR = "Constructor %s requires 'new'"; static const char *const dyn_global_ctor_names[] = { dyn_s26, dyn_s29, dyn_s25, dyn_s30, dyn_s31, dyn_s21, dyn_s22, dyn_s24, dyn_s35, + dyn_s42, dyn_s43, dyn_s44, dyn_s45, dyn_s46, }; #define DYN_GLOBAL_CTORS (sizeof dyn_global_ctor_names / sizeof dyn_global_ctor_names[0]) +/* Each constructor's own length, indexed like dyn_global_ctor_names. */ +static const uint8_t dyn_global_ctor_lengths[DYN_GLOBAL_CTORS] = {1, 7, 2, 0, 0, 1, 0, 0, 1, 1, 1, 3, 3, 2}; +/* The first of the constructors that only `new` builds + * (SharedArrayBuffer onward): calling one throws Node's TypeError. */ +#define DYN_GLOBAL_NEW_ONLY 9 static SCR_TL ScrDyn *dyn_global_ctors[DYN_GLOBAL_CTORS]; static SCR_TL bool dyn_global_ctors_registered; @@ -2029,6 +2043,13 @@ static void dyn_global_ctors_cleanup(void) { static ScrDyn *dyn_global_ctor_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { const char *name = closure->fn; + for (size_t i = DYN_GLOBAL_NEW_ONLY; i < DYN_GLOBAL_CTORS; i++) { + if (name != dyn_global_ctor_names[i]) continue; + char message[64]; + int length = snprintf(message, sizeof message, dyn_s47, name); + scr_throw_error_msg(SCR_ERR_TYPE, message, (size_t)length); + return NULL; + } bool object = strcmp(name, dyn_s26) == 0; if (object) { const ScrDyn *value = argc ? args[0] : scr_dyn_undefined(); @@ -2046,8 +2067,8 @@ static ScrDyn *dyn_global_ctor_call(ScrClosure *closure, ScrDyn *const *args, si } /* `new C(...args)` through a stored Object, Date, Map, Set, WeakMap or - * WeakSet builds what the `new` expression builds. RegExp, Promise, and - * Object over a primitive (a wrapper object) are fenced. */ + * WeakSet builds what the `new` expression builds. The other constructors, + * and Object over a primitive (a wrapper object), are fenced. */ static bool dyn_global_construct(const ScrDyn *callee, const ScrDyn *args, ScrDyn **result) { if (callee->kind != SCR_DYN_FUNC || callee->v.fn.thunk != dyn_global_ctor_call) return false; const char *name = callee->v.fn.clo->fn; @@ -2083,11 +2104,8 @@ static ScrDyn *dyn_global_ctor(const char *name) { scr_dyn_global_construct_fn = dyn_global_construct; } if (!dyn_global_ctors[i]) { - /* Each constructor's own length: Object 1, Date 7, RegExp 2, Promise 1, - * the collections 0, FinalizationRegistry 1. */ - uint32_t length = i == 0 || i == 5 || i == 8 ? 1 : i == 1 ? 7 : i == 2 ? 2 : 0; dyn_global_ctors[i] = scr_dyn_new_func(scr_closure_new((void *)dyn_global_ctor_names[i], 0), - dyn_global_ctor_call, length, dyn_s6, + dyn_global_ctor_call, dyn_global_ctor_lengths[i], dyn_s6, dyn_global_ctor_names[i]); /* `o.constructor.prototype` of a plain object is Object.prototype * (zod's isPlainObject). The other prototypes are not modeled. */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 311596597..2668278c5 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5538,10 +5538,26 @@ static int scr_dyn_global_constructor_instanceof(const ScrDyn *value, const ScrD return -1; /* A native Date, or an instance of a class declared `extends Date`. */ if (strcmp(callee->v.fn.name, "Date") == 0) return scr_dyn_date_instance_is(value); + if (strcmp(callee->v.fn.name, "DataView") == 0) return scr_dyn_data_view_is(value); + /* No value is a BigInt64Array or BigUint64Array: neither is constructed. */ + if (strcmp(callee->v.fn.name, "BigInt64Array") == 0 || strcmp(callee->v.fn.name, "BigUint64Array") == 0) + return 0; + /* Proxy has no prototype: false for a primitive, Node's TypeError for an + * object. */ + if (strcmp(callee->v.fn.name, "Proxy") == 0) { + if (value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_BOOL || + value->kind == SCR_DYN_NUM || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BIGINT || + value->kind == SCR_DYN_SYMBOL) + return 0; + static const char message[] = "Function has non-object prototype 'undefined' in instanceof check"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return 0; + } static const struct { const char *name; int tag; } kinds[] = { {"RegExp", SCR_DYNH_REGEXP}, {"Map", SCR_DYNH_MAP}, {"Set", SCR_DYNH_SET}, {"WeakMap", SCR_DYNH_WEAK_MAP}, {"WeakSet", SCR_DYNH_WEAK_SET}, {"FinalizationRegistry", SCR_DYNH_FINALIZATION_REGISTRY}, + {"SharedArrayBuffer", SCR_DYNH_SHARED_ARRAY_BUFFER}, }; bool promise = strcmp(callee->v.fn.name, "Promise") == 0; int tag = -1; diff --git a/tests/corpus/builtin-values-proxy-intl-views.cjs b/tests/corpus/builtin-values-proxy-intl-views.cjs new file mode 100644 index 000000000..2cde73bb4 --- /dev/null +++ b/tests/corpus/builtin-values-proxy-intl-views.cjs @@ -0,0 +1,33 @@ +// SharedArrayBuffer, DataView, BigInt64Array, BigUint64Array, Proxy and Intl +// read as values hold Node's functions and namespace object. +function attempt(label, run) { + try { + console.log(label, run()); + } catch (error) { + console.log(label, error.name, error.message); + } +} + +const constructors = { SharedArrayBuffer, DataView, BigInt64Array, BigUint64Array, Proxy }; +for (const key of Object.keys(constructors)) { + const value = constructors[key]; + console.log(key, typeof value, value.name, value.length, value === globalThis[key]); + attempt(`call ${key}`, () => value()); +} +console.log(constructors); +console.log(String(DataView), Object.prototype.toString.call(constructors.Proxy)); + +const shared = new SharedArrayBuffer(8); +const view = new DataView(new ArrayBuffer(4)); +const stored = { shared: SharedArrayBuffer, view: DataView, proxy: Proxy, big: BigInt64Array }; +console.log(shared instanceof stored.shared, new ArrayBuffer(1) instanceof stored.shared); +console.log(view instanceof stored.view, new Uint8Array(4) instanceof stored.view); +console.log(view instanceof stored.big, JSON.parse("1") instanceof stored.proxy); +attempt("instanceof Proxy", () => ({}) instanceof stored.proxy); + +const intl = Intl; +console.log(typeof intl, intl === globalThis.Intl, Object.keys(intl).length, "NumberFormat" in intl); +console.log(Object.getOwnPropertyNames(intl).join(",")); +console.log(Object.prototype.toString.call(intl), intl[Symbol.toStringTag]); +console.log(typeof intl.DateTimeFormat, intl.Locale.length, intl.DisplayNames.length); +console.log(new Intl.NumberFormat("en-US").format(1234.5)); diff --git a/tests/corpus/builtin-values-untyped-slots.ts b/tests/corpus/builtin-values-untyped-slots.ts index 4185a524e..c32857ef6 100644 --- a/tests/corpus/builtin-values-untyped-slots.ts +++ b/tests/corpus/builtin-values-untyped-slots.ts @@ -51,3 +51,14 @@ console.log( Object.keys(math).length, Object.getOwnPropertyNames(json).join(","), ); + +const views: any = { intl: Intl, proxy: Proxy, view: DataView, shared: SharedArrayBuffer }; +console.log(typeof views.intl, Object.getOwnPropertyNames(views.intl).length); +console.log(Object.prototype.toString.call(views.intl), views.intl.Locale.length); +console.log(views.proxy.name, views.proxy.length, views.view.name, views.shared.length); +try { + views.proxy(); +} catch (error) { + console.log((error as Error).message); +} +console.log(views); diff --git a/tests/diagnostics/builtin-values-without-lowering.ts b/tests/diagnostics/builtin-values-without-lowering.ts index b8b5c90a1..6ea93c949 100644 --- a/tests/diagnostics/builtin-values-without-lowering.ts +++ b/tests/diagnostics/builtin-values-without-lowering.ts @@ -5,7 +5,7 @@ function take(x: unknown): unknown { } const inferred = { json: JSON }; -const loose: any = { intl: Intl, proxy: Proxy }; +const loose: any = { atomics: Atomics }; const timers: Record = { setTimeout }; const list: any[] = [Array, parseInt]; console.log(inferred, loose, timers, list, take(process)); diff --git a/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt b/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt index 418eaedef..466015d88 100644 --- a/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt +++ b/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt @@ -3,7 +3,7 @@ builtin-values-without-lowering.ts:7:7 - error SC2009: values of type '{ json: J 6 | 7 | const inferred = { json: JSON }; | ^~~~~~~~ - 8 | const loose: any = { intl: Intl, proxy: Proxy }; + 8 | const loose: any = { atomics: Atomics }; hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') @@ -12,22 +12,22 @@ builtin-values-without-lowering.ts:7:18 - error SC2009: values of type '{ json: 6 | 7 | const inferred = { json: JSON }; | ^~~~~~~~~~~~~~ - 8 | const loose: any = { intl: Intl, proxy: Proxy }; + 8 | const loose: any = { atomics: Atomics }; hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -builtin-values-without-lowering.ts:8:28 - error SC2020: 'Intl' is part of the standard library types but has no scriptc lowering yet +builtin-values-without-lowering.ts:8:31 - error SC2020: 'Atomics' is part of the standard library types but has no scriptc lowering yet 7 | const inferred = { json: JSON }; - 8 | const loose: any = { intl: Intl, proxy: Proxy }; - | ^~~~ + 8 | const loose: any = { atomics: Atomics }; + | ^~~~~~~ 9 | const timers: Record = { setTimeout }; - hint: native Intl supports Intl.Segmenter with grapheme or word granularity and the composed new Intl.NumberFormat("en-US").format(x), x.toLocaleString("en-US") and x.toLocaleString(); formatter objects, locale negotiation and the other Intl constructors remain unsupported + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) builtin-values-without-lowering.ts:9:43 - error SC2020: 'setTimeout' is part of the standard library types but has no scriptc lowering yet - 8 | const loose: any = { intl: Intl, proxy: Proxy }; + 8 | const loose: any = { atomics: Atomics }; 9 | const timers: Record = { setTimeout }; | ^~~~~~~~~~ 10 | const list: any[] = [Array, parseInt]; diff --git a/tests/harness/corpus-costs/builtin-values-proxy-intl-views.cjs.json b/tests/harness/corpus-costs/builtin-values-proxy-intl-views.cjs.json new file mode 100644 index 000000000..5c169787d --- /dev/null +++ b/tests/harness/corpus-costs/builtin-values-proxy-intl-views.cjs.json @@ -0,0 +1 @@ +{ "plain": 4800, "san": 7200 } From a119816d9bd04a7a41059d4d906ff65e2722002a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 06:33:07 -0500 Subject: [PATCH 42/44] Keep the runtime's libm definitions in musl links without lld A Linux link that does not use lld leaves out scr_glibc_math.o, because GNU ld keeps glibc's libm.so needed when the unit's unused code is linked. A link through SCRIPTC_LINKER counts as such a link, so a musl executable linked through a wrapper (bench:runtime --layouts among them) took the libc's floor, ceil and trunc instead of the runtime's. A musl executable is static and has no libm.so, so its links now keep the unit. --- packages/compiler/src/backend/linker.ts | 2 +- packages/compiler/src/backend/lld.test.ts | 14 ++++++++++---- packages/compiler/src/backend/lld.ts | 11 ++++++++--- packages/compiler/src/native/compiler.ts | 2 +- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index 66a417582..fa7af8c84 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -294,7 +294,7 @@ export async function linkNativeExecutable( ...hugeTextLinkerArgs(plan.hugeText === true, (withLld && lld.length !== 0) || zig), ...plan.driverFlags, ...mapArgs, - ...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)), + ...(withLld ? inputs : inputsWithoutLld(plan.target, inputs)), ...systemLibraryLinkerArgs(plan.target, plan.systemLibraries), "-o", privateOut, diff --git a/packages/compiler/src/backend/lld.test.ts b/packages/compiler/src/backend/lld.test.ts index 048f2ab94..e8d87cbf0 100644 --- a/packages/compiler/src/backend/lld.test.ts +++ b/packages/compiler/src/backend/lld.test.ts @@ -60,9 +60,15 @@ test.skipIf(process.platform === "win32")( }, ); -test("Linux links without lld leave out the glibc math unit", () => { +test("glibc links without lld leave out the glibc math unit", () => { const inputs = ["/pack/scr_lib.o", "/pack/scr_glibc_math.o", "/tmp/program.o"]; - expect(inputsWithoutLld("linux", inputs)).toEqual(["/pack/scr_lib.o", "/tmp/program.o"]); - expect(inputsWithoutLld("darwin", inputs)).toEqual(inputs); - expect(inputsWithoutLld("windows", inputs)).toEqual(inputs); + const target = (platform: string, name: string) => ({ platform, name }); + expect(inputsWithoutLld(target("linux", "linux-x64-gnu"), inputs)).toEqual([ + "/pack/scr_lib.o", + "/tmp/program.o", + ]); + expect(inputsWithoutLld(target("linux", "linux-x64-musl"), inputs)).toEqual(inputs); + expect(inputsWithoutLld(target("linux", "linux-arm64-musl"), inputs)).toEqual(inputs); + expect(inputsWithoutLld(target("darwin", "darwin-arm64"), inputs)).toEqual(inputs); + expect(inputsWithoutLld(target("windows", "win32-x64-msvc"), inputs)).toEqual(inputs); }); diff --git a/packages/compiler/src/backend/lld.ts b/packages/compiler/src/backend/lld.ts index 7a9210407..afea55584 100644 --- a/packages/compiler/src/backend/lld.ts +++ b/packages/compiler/src/backend/lld.ts @@ -58,10 +58,15 @@ export function lldLinkerArgs( * libm needed, and it keeps every regular definition of a name that a needed * shared library also defines, hidden or not: the unit would add all of its * code to every executable and save nothing. Its functions answer what - * glibc's do, so linking glibc's own instead is equivalent. + * glibc's do, so linking glibc's own instead is equivalent. A musl + * executable is static and has no libm.so to keep needed, so its links keep + * the unit, which replaces the libc's slower definitions there. */ -export function inputsWithoutLld(platform: string, inputs: readonly string[]): string[] { - return platform === "linux" +export function inputsWithoutLld( + target: { readonly platform: string; readonly name: string }, + inputs: readonly string[], +): string[] { + return target.platform === "linux" && !target.name.endsWith("-musl") ? inputs.filter((input) => basename(input) !== "scr_glibc_math.o") : [...inputs]; } diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index a8b61498b..f0ef6ce6c 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -668,7 +668,7 @@ export class NativeCompiler { ...hugeTextLinkerArgs(hugeText, (withLld && lld.length !== 0) || zig), ...plan.driverFlags, ...mapArgs, - ...(withLld ? plan.inputs : inputsWithoutLld(target.platform, plan.inputs)), + ...(withLld ? plan.inputs : inputsWithoutLld(target, plan.inputs)), ...systemLibraryLinkerArgs(target, plan.systemLibraries), "-o", stagedOutput, From c38a8035b3dc6812b9bc8ae11753276a057ee263 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 30 Oct 2026 05:32:14 -0700 Subject: [PATCH 43/44] frontend: lower entries().filter in generic bodies, name superset filter refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.entries(value).filter(([, item]) => ...)` inside a generic body spells the entry element as a deferred conditional over the lib overload's own type parameter (`[string, keyof T extends never ? unknown : T[keyof T]]`), which no bound-type-parameter resolver can decide. The array-method entry then found no mapping for the receiver type and fenced the call with SC2020, even though the `Object.entries` lowering answers the concrete `[string, ][]` the value really has. When the checker's receiver type is an array that the tables cannot map, fall back to the lowered value's array type, as the checker-untyped probe above does (re-lowering is pure IR construction). The stdlib member gate still runs, because the checker does type this receiver, so only calls that were previously refused can change. A `.filter` type predicate whose record target declares fields the receiver element type has no slot for stays refused, but the SC1090 message now names the cause instead of a generic missing conversion: each element was narrowed when it was STORED, so those fields do not exist at runtime and the predicate cannot make them real. The sound paths already work and match Node: a checked-dynamic (`unknown[]`) receiver verifies every target field at the narrowed element, and an index-signature receiver reads the target's fields out of its overflow. Corpus programs: - 8741-mixed-tuple-array-slots: `["one"] | string[]` through a `Record` read, a `string[][]` literal, spread, a `readonly string[]` binding, a ternary, and parameter position — every shape Node accepts stays an array and never traps. - 8753-filter-predicate-superset-guard: the superset guard as an inline annotation on a checked-dynamic receiver (honest per-field tests; a lying predicate throws the catchable TypeError instead of handing out invented fields) and as a guard function on an index-signature receiver. - 8768-entries-filter-generic-instantiation: `entries().filter` inside `compact`, a `const` binding with `.map`/`.some`, and a written predicate over the same receiver. `tests/diagnostics/filter-predicate-superset-record.ts` pins the refusal and its new message. Co-Authored-By: Claude 5.5 --- .../lowering/containers/array-callbacks.ts | 19 ++++- .../lowering/containers/array-methods.ts | 15 ++++ .../lowering/containers/filter-predicates.ts | 25 +++++++ .../filter-predicate-superset-guard.test.ts | 69 +++++++++++++++++++ .../8741-mixed-tuple-array-slots.ts.json | 6 ++ ...53-filter-predicate-superset-guard.ts.json | 6 ++ ...tries-filter-generic-instantiation.ts.json | 6 ++ .../filter-predicate-superset-record.ts.json | 6 ++ tests/corpus/8741-mixed-tuple-array-slots.ts | 32 +++++++++ .../8753-filter-predicate-superset-guard.ts | 46 +++++++++++++ ...68-entries-filter-generic-instantiation.ts | 36 ++++++++++ .../filter-predicate-superset-record.ts | 26 +++++++ .../filter-predicate-superset-record.ts.txt | 13 ++++ .../8741-mixed-tuple-array-slots.ts.json | 1 + ...53-filter-predicate-superset-guard.ts.json | 1 + ...tries-filter-generic-instantiation.ts.json | 1 + 16 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/filter-predicate-superset-guard.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8741-mixed-tuple-array-slots.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8753-filter-predicate-superset-guard.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8768-entries-filter-generic-instantiation.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/filter-predicate-superset-record.ts.json create mode 100644 tests/corpus/8741-mixed-tuple-array-slots.ts create mode 100644 tests/corpus/8753-filter-predicate-superset-guard.ts create mode 100644 tests/corpus/8768-entries-filter-generic-instantiation.ts create mode 100644 tests/diagnostics/filter-predicate-superset-record.ts create mode 100644 tests/harness/__snapshots__/filter-predicate-superset-record.ts.txt create mode 100644 tests/harness/corpus-costs/8741-mixed-tuple-array-slots.ts.json create mode 100644 tests/harness/corpus-costs/8753-filter-predicate-superset-guard.ts.json create mode 100644 tests/harness/corpus-costs/8768-entries-filter-generic-instantiation.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index ff4d9efaf..7c1a03f90 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -69,7 +69,7 @@ import { } from "./array-iteration.js"; import { dynamicReceiverThrows } from "./dynamic-receivers.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; -import { checkedFilterNarrowing } from "./filter-predicates.js"; +import { checkedFilterNarrowing, supersetGuardFields } from "./filter-predicates.js"; import { omittableTrailingParam } from "../coercions/function-adapters.js"; /** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct @@ -262,6 +262,19 @@ function filterKeptElem( "keep the receiver's element type instead — annotate the callback's return ': boolean' " + "(the checker then skips the predicate) or annotate the result with the receiver's own " + "element type — and narrow the elements after"; + // A record target that declares fields the receiver's record element has no + // slot for: the elements were narrowed when they were stored, so those + // fields no longer exist at runtime and no per-field check can recover them. + const widened = supersetGuardFields(lowerer, elem, outElem); + const missingFields = + widened === null + ? "" + : ` (the receiver's element type has no slot for ${widened + .slice(0, 4) + .map((field) => `'${field}'`) + .join(", ")}${widened.length > 4 ? ` and ${widened.length - 4} more` : ""} — each ` + + `element was narrowed when it was stored, so those fields do not exist at runtime and ` + + `the predicate cannot make them real)`; const written = !booleanFilter && ((!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) || @@ -278,14 +291,14 @@ function filterKeptElem( "SC1090", argNode, `narrowing '.filter' to '${lowerer.fmt(outElem)}' through a callback VALUE (no checked ` + - `conversion from '${lowerer.fmt(elem)}' — ${annotateEscape})`, + `conversion from '${lowerer.fmt(elem)}'${missingFields} — ${annotateEscape})`, ); } lowerer.unsupported( "SC1090", argNode, `narrowing '.filter' to '${lowerer.fmt(outElem)}' with a hand-written type predicate (no ` + - `checked conversion from '${lowerer.fmt(elem)}' — ${annotateEscape})`, + `checked conversion from '${lowerer.fmt(elem)}'${missingFields} — ${annotateEscape})`, ); } const classRefinement = diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 6fd7118b0..6341ccbc6 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -543,6 +543,21 @@ export function lowerArrayMethodCall( : dispatched; } } + // A checker ARRAY type with no mapping whose VALUE lowers to a static + // array: `Object.entries(value).filter(([, item]) => ...)` inside a + // generic body, where the checker spells the entry element as a deferred + // conditional over the lib overload's own type parameter + // (`[string, keyof T extends never ? unknown : T[keyof T]]`) that no + // bound-type-parameter resolver can decide, while the Object.entries + // lowering answers the concrete tuple array the value actually has. Ride + // the ordinary tables on the lowered element type, as the checker-untyped + // probe above does (re-lowering is pure IR construction); the stdlib + // member gate below still applies, because the checker does type this + // receiver. + if (receiverIr === null && lowerer.checker.isArrayType(checkerReceiver)) { + const probe = lowerer.lowerExpr(access.expression); + if (probe.type.kind === "array") receiverIr = probe.type; + } if (receiverIr?.kind === "union") { const unionHof = lowerArrayUnionHofCall(lowerer, call, access, receiverIr); if (unionHof) return unionHof; diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 421d703d1..aa6033487 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -71,6 +71,31 @@ export function checkedFilterNarrowing( return name; } +/** The fields a written predicate's record target declares that the + * receiver's record element has no slot for, or null when there are none + * (or when the pair is not a plain record narrowing). + * + * An element narrowed when it was STORED (`const messages: { role: string }[] + * = [wider, ...]` copies each wider object into the element's layout) lost + * those fields before the filter ran: the predicate cannot make them real, + * so the narrowing stays refused with this reason instead of a generic + * missing-conversion report. */ +export function supersetGuardFields( + lowerer: Lowerer, + from: IrType, + to: IrType, +): readonly string[] | null { + if (from.kind !== "record" || to.kind !== "record" || from.shapeId === to.shapeId) return null; + const source = lowerer.shapes.get(from.shapeId); + const target = lowerer.shapes.get(to.shapeId); + if (!source || !target) return null; + if (source.tuple || target.tuple || source.indexValue || target.indexValue) return null; + const absent = target.fields.filter( + (field) => !source.fields.some((own) => own.name === field.name), + ); + return absent.length === 0 ? null : absent.map((field) => field.name); +} + /** The interned helper converting one `from` value to `to`, or null. */ function elementHelper( lowerer: Lowerer, diff --git a/packages/compiler/test/filter-predicate-superset-guard.test.ts b/packages/compiler/test/filter-predicate-superset-guard.test.ts new file mode 100644 index 000000000..aa13428ee --- /dev/null +++ b/packages/compiler/test/filter-predicate-superset-guard.test.ts @@ -0,0 +1,69 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +/* A `.filter` type predicate whose target is a SUPERSET of the receiver + * element's declared fields is narrowed to what the value really carries: a + * checked-dynamic source's own properties are read and verified field by + * field at the narrowed element, so a predicate that keeps a value the + * checks do not describe throws the catchable TypeError instead of handing + * out a record with invented fields. Node yields `undefined` reads for the + * same program (the record-layout divergence). + * + * The truthful predicate below narrows exactly the element that carries all + * three fields; the lying one keeps an element without `content`. */ +test("a superset `.filter` guard is verified field by field", async () => { + const dir = mkdtempSync("/tmp/scriptc-filter-superset-"); + try { + const entry = join(dir, "main.ts"); + writeFileSync( + entry, + ` +interface SystemMessage { role: "system"; content: string; timestamp: number } +const raw: unknown[] = [ + { role: "system", content: "json", timestamp: 20 }, + { role: "user", content: "hi" }, + { role: "system", timestamp: 21 }, +]; +const isText = (v: unknown): boolean => typeof v === "string"; +function honest(r: unknown): r is SystemMessage { + const row = r as { role?: unknown; content?: unknown; timestamp?: unknown }; + return typeof row.role === "string" && row.role === "system" && + isText(row.content) && typeof row.timestamp === "number"; +} +const kept = raw.filter(honest); +console.log("kept", kept.length, kept.map((m) => m.content + ":" + m.timestamp).join(",")); +// A guard that returns true for an element the checks do not describe. +const lying = (r: unknown): r is SystemMessage => (r as { role: string }).role === "system"; +try { + for (const message of raw.filter(lying)) console.log("read", message.content); +} catch (error) { + console.log("rejected", error instanceof TypeError); +} +`, + ); + const result = await compile(entry, { + outDir: dir, + outPath: join(dir, "program"), + sanitize: process.env["SCRIPTC_SAN"] === "1", + }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const child = spawnSync(result.binaryPath, [], { encoding: "utf8", timeout: 30_000 }); + expect(child.error).toBeUndefined(); + expect({ + status: child.status, + signal: child.signal, + stdout: child.stdout, + stderr: child.stderr, + }).toEqual({ + status: 0, + signal: null, + stderr: "", + stdout: "kept 1 json:20\nrejected true\n", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8741-mixed-tuple-array-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8741-mixed-tuple-array-slots.ts.json new file mode 100644 index 000000000..f75cba7c9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8741-mixed-tuple-array-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8741-mixed-tuple-array-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8753-filter-predicate-superset-guard.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8753-filter-predicate-superset-guard.ts.json new file mode 100644 index 000000000..6d5737400 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8753-filter-predicate-superset-guard.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8753-filter-predicate-superset-guard.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8768-entries-filter-generic-instantiation.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8768-entries-filter-generic-instantiation.ts.json new file mode 100644 index 000000000..bbc328c77 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8768-entries-filter-generic-instantiation.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8768-entries-filter-generic-instantiation.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/filter-predicate-superset-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/filter-predicate-superset-record.ts.json new file mode 100644 index 000000000..ba3975ce9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/filter-predicate-superset-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/filter-predicate-superset-record.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8741-mixed-tuple-array-slots.ts b/tests/corpus/8741-mixed-tuple-array-slots.ts new file mode 100644 index 000000000..fc8b4d011 --- /dev/null +++ b/tests/corpus/8741-mixed-tuple-array-slots.ts @@ -0,0 +1,32 @@ +// A mixed tuple/array union (`["one"] | string[]`) in the slots 8513 does +// not cover: read out of an index-signature record, nested inside a +// `string[][]` literal, spread, and in parameter position. Node has one +// array in every one of them, so no arm may trap. +type Mixed = ["one"] | string[]; + +function store(): Record { + return { a: ["x"], b: ["y", "z"] }; +} +const table = store(); +const fromRecord: string[] = table["a"]!; +const fromSecond: string[] = table["b"]!; +console.log("record", fromRecord.length, fromRecord[0], fromSecond.join("+")); + +function width(values: string[]): number { + return values.length; +} +const tupleArm: Mixed = ["one"]; +const arrayArm: Mixed = ["two", "three"]; +console.log("param", width(tupleArm), width(arrayArm)); + +const nested: string[][] = [tupleArm, arrayArm]; +console.log("nested", nested.length, nested[0]!.join("+"), nested[1]!.join("+")); + +const spread: string[][] = [...nested, fromSecond, ["tail"]]; +console.log("spread", spread.map((v) => v.join("")).join(",")); + +const readonly: readonly string[] = tupleArm; +console.log("readonly", readonly.length, readonly[0]); + +const fromTernary: string[] = Math.random() > 2 ? tupleArm : arrayArm; +console.log("ternary", fromTernary.join("")); diff --git a/tests/corpus/8753-filter-predicate-superset-guard.ts b/tests/corpus/8753-filter-predicate-superset-guard.ts new file mode 100644 index 000000000..8de247c7b --- /dev/null +++ b/tests/corpus/8753-filter-predicate-superset-guard.ts @@ -0,0 +1,46 @@ +// `.filter` with a type predicate whose target is a SUPERSET of the +// receiver element's declared fields. The narrowed element is only ever +// what the value really carries: a checked-dynamic source's own properties +// are read and verified field by field at the narrowed element, never +// trusted from the predicate (a predicate that keeps a value the checks do +// not describe throws the catchable TypeError instead of inventing fields). +interface SystemMessage { + role: "system"; + content: string; + timestamp: number; +} + +const isText = (v: unknown): boolean => typeof v === "string"; +// A checked-dynamic receiver (`unknown[]`): the predicate's tests are the +// only evidence, so the narrowing checks every field of the target. +const raw: unknown[] = [ + { role: "system", content: "json", timestamp: 20 }, + { role: "user", content: "hi" }, + { role: "system", timestamp: 21 }, +]; +const system = raw.filter( + (r): r is SystemMessage => + typeof (r as { role?: unknown }).role === "string" && + (r as { role: string }).role === "system" && + isText((r as { content?: unknown }).content) && + typeof (r as { timestamp?: unknown }).timestamp === "number", +); +console.log("count", system.length); +for (const message of system) console.log(message.role, message.content, message.timestamp); + +function stamp(message: SystemMessage): string { + return `${message.timestamp}:${message.content}`; +} +console.log("map", system.map(stamp).join("|")); + +// A record with an index signature holds the target's fields in its +// overflow, so the same narrowing is exact there: the fields the record +// does not declare are read from the overflow, absent ones stay absent. +type Row = { role: string; [key: string]: unknown }; +const rows: Row[] = [{ role: "system", content: "base", timestamp: 10 }, { role: "user" }]; +function isSystem(m: { role: string }): m is SystemMessage { + return m.role === "system"; +} +const fromRow = rows.filter(isSystem); +console.log("row", fromRow.length); +for (const message of fromRow) console.log(message.role, message.content, message.timestamp); diff --git a/tests/corpus/8768-entries-filter-generic-instantiation.ts b/tests/corpus/8768-entries-filter-generic-instantiation.ts new file mode 100644 index 000000000..ed4a26eb4 --- /dev/null +++ b/tests/corpus/8768-entries-filter-generic-instantiation.ts @@ -0,0 +1,36 @@ +// `Object.entries(value).filter(...)` inside a generic body. The checker +// spells the entry element as a deferred conditional over the lib +// overload's own type parameter +// (`[string, keyof T extends never ? unknown : T[keyof T]]`), which has no +// mapping; the filter rides the Object.entries lowerings concrete +// `[string, ][]` instead, so the predicate sees the values the +// record really holds. +function compact(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== undefined), + ) as Partial; +} + +const options = { client_id: "abc", error: undefined as string | undefined, meta: { on: true } }; +console.log(JSON.stringify(compact(options))); +console.log(JSON.stringify(compact({ a: 1, b: 2 }))); + +// The same receiver through a `const` binding and through `.map`/`.some`: +// one lowered entry array either way. +function describe(value: T): string { + const entries = Object.entries(value); + const defined = entries.filter(([, item]) => item !== undefined); + const mapped = defined.map(([key, item]) => `${key}=${String(item)}`); + const anyObject = mapped.some((part) => part.includes("meta")); + return `${entries.length}/${defined.length}/${anyObject}:${mapped.join("&")}`; +} +console.log(describe({ a: 1, b: undefined as number | undefined, meta: { on: true } })); + +// A written predicate over the same receiver narrows to the value union. +function numbers(value: T): string { + return Object.entries(value) + .filter((entry): entry is [string, number] => typeof entry[1] === "number") + .map(([key, item]) => `${key}:${item + 1}`) + .join(","); +} +console.log(numbers({ a: 1, b: "x", c: 3 })); diff --git a/tests/diagnostics/filter-predicate-superset-record.ts b/tests/diagnostics/filter-predicate-superset-record.ts new file mode 100644 index 000000000..8519f14e2 --- /dev/null +++ b/tests/diagnostics/filter-predicate-superset-record.ts @@ -0,0 +1,26 @@ +// `.filter` with a type predicate whose target declares fields the receiver +// element type has no slot for. The elements were narrowed when they were +// STORED (`const messages: { role: string }[] = [wider, ...]` copies each +// wider object into the element's layout), so `content`/`timestamp` no +// longer exist at runtime: the predicate cannot make them real, and trusting +// it would hand out a value whose fields are missing. Node would answer +// `undefined` reads here; that is the record-layout divergence the refusal +// keeps out of reach — declare the wider element type at the receiver. +interface SystemMessage { + role: "system"; + content: string; + timestamp: number; +} +type TranscriptMessages = readonly { role: string }[]; + +function isSystemMessage(message: { role: string }): message is SystemMessage { + return message.role === "system"; +} + +const first = { role: "system", content: "base", timestamp: 1 }; +const second = { role: "user", content: "hi", timestamp: 2 }; +const messages: TranscriptMessages = [first, second]; +// A guard function passed as a value. +console.log(messages.filter(isSystemMessage).length); +// The same narrowing written inline. +console.log(messages.filter((m): m is SystemMessage => m.role === "system").length); diff --git a/tests/harness/__snapshots__/filter-predicate-superset-record.ts.txt b/tests/harness/__snapshots__/filter-predicate-superset-record.ts.txt new file mode 100644 index 000000000..ab2ea468b --- /dev/null +++ b/tests/harness/__snapshots__/filter-predicate-superset-record.ts.txt @@ -0,0 +1,13 @@ +filter-predicate-superset-record.ts:24:29 - error SC1090: narrowing '.filter' to '{ content: string; role: string; timestamp: number }' through a callback VALUE (no checked conversion from '{ role: string }' (the receiver's element type has no slot for 'content', 'timestamp' — each element was narrowed when it was stored, so those fields do not exist at runtime and the predicate cannot make them real) — keep the receiver's element type instead — annotate the callback's return ': boolean' (the checker then skips the predicate) or annotate the result with the receiver's own element type — and narrow the elements after) is not supported yet + + 23 | // A guard function passed as a value. + 24 | console.log(messages.filter(isSystemMessage).length); + | ^~~~~~~~~~~~~~~ + 25 | // The same narrowing written inline. + +filter-predicate-superset-record.ts:26:29 - error SC1090: narrowing '.filter' to '{ content: string; role: string; timestamp: number }' with a hand-written type predicate (no checked conversion from '{ role: string }' (the receiver's element type has no slot for 'content', 'timestamp' — each element was narrowed when it was stored, so those fields do not exist at runtime and the predicate cannot make them real) — keep the receiver's element type instead — annotate the callback's return ': boolean' (the checker then skips the predicate) or annotate the result with the receiver's own element type — and narrow the elements after) is not supported yet + + 25 | // The same narrowing written inline. + 26 | console.log(messages.filter((m): m is SystemMessage => m.role === "system").length); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 27 | \ No newline at end of file diff --git a/tests/harness/corpus-costs/8741-mixed-tuple-array-slots.ts.json b/tests/harness/corpus-costs/8741-mixed-tuple-array-slots.ts.json new file mode 100644 index 000000000..a56e13591 --- /dev/null +++ b/tests/harness/corpus-costs/8741-mixed-tuple-array-slots.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 1900 } diff --git a/tests/harness/corpus-costs/8753-filter-predicate-superset-guard.ts.json b/tests/harness/corpus-costs/8753-filter-predicate-superset-guard.ts.json new file mode 100644 index 000000000..ad1941ccd --- /dev/null +++ b/tests/harness/corpus-costs/8753-filter-predicate-superset-guard.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 2100 } diff --git a/tests/harness/corpus-costs/8768-entries-filter-generic-instantiation.ts.json b/tests/harness/corpus-costs/8768-entries-filter-generic-instantiation.ts.json new file mode 100644 index 000000000..bfedbf22f --- /dev/null +++ b/tests/harness/corpus-costs/8768-entries-filter-generic-instantiation.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2300 } From 717ddfdfe336847d388c098fa8d6a3115d2ebf18 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 07:40:27 -0500 Subject: [PATCH 44/44] Keep the names of functions boxed inside records and arrays A function value converted to unknown on its own takes its name from the conversion site, but one held in a record, an array, a map or another converted value had no name there, so util.inspect printed `[Function (anonymous)]` where Node prints `[Function: toString]`. The function box now looks a nameless function up in a per-module table of (entry point, name) pairs, built from the functions the module makes closures of: each declared or lifted function's sourceName, which follows NamedEvaluation, and for an object literal's method its key (IrFunction.displayName). An adapter is looked up through the function it adapts. displayName is set only in modules that box a value that can hold a function, and the table exists only in modules that box functions. --- packages/compiler/src/backend/llvm/dyn.ts | 104 +++++++++++++++++- packages/compiler/src/backend/llvm/emitter.ts | 30 +++++ .../compiler/src/backend/llvm/expr-dynamic.ts | 8 +- .../src/frontend/lowering/function-names.ts | 99 +++++++++++++++++ .../src/frontend/lowering/lower-functions.ts | 2 + .../compiler/src/frontend/lowering/lowerer.ts | 6 + packages/compiler/src/ir/codec.generated.ts | 46 ++++---- .../src/ir/function-references.test.ts | 1 + packages/compiler/src/ir/ir.ts | 4 + .../tests/corpus/boxed-function-names.ts.json | 6 + tests/corpus/boxed-function-names.ts | 30 +++++ .../corpus-costs/boxed-function-names.ts.json | 1 + 12 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/function-names.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/boxed-function-names.ts.json create mode 100644 tests/corpus/boxed-function-names.ts create mode 100644 tests/harness/corpus-costs/boxed-function-names.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index e9dc81c87..51b9c8543 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -223,6 +223,9 @@ export interface DynHost extends WalkerHost { /** The closures with a jsval body (IrFunction.jsEntry) on targets with * jsval words, which the box builders give a jsval entry. */ jsEntryClosures?(): readonly JsEntryClosure[]; + /** The entry point and JavaScript name of each named function the module + * makes closures of (LlEmitter.closureNames). */ + closureNames?(): readonly { entry: string; name: string }[]; } /** A closure with a jsval body (IrFunction.jsEntry), for the box builders. */ @@ -309,6 +312,8 @@ export class LlDyn { return this.dynFuncBoxHelper(args[0] as IrType & { kind: "func" }); case "dyn.jsEntryThunk": return this.jsEntryThunk(args[0] as string, args[1] as IrType[], args[2] as IrType); + case "dyn.unnamedFuncBoxHelper": + return this.unnamedFuncBoxHelper(args[0] as IrType & { kind: "func" }) ?? ""; case "dyn.dynFuncAdapterHelper": return this.dynFuncAdapterHelper(args[0] as IrType & { kind: "func" }); case "dyn.dynToStrHelper": @@ -3561,7 +3566,7 @@ export class LlDyn { } case "func": { const r = B.tmp(); - B.line(`${r} = call ptr @${this.dynFuncBoxHelper(t)}(ptr %v, ptr null)`); + B.line(`${r} = ${this.unnamedFuncBoxCall(t, "%v")}`); B.terminate(`ret ptr ${r}`); break; } @@ -3972,7 +3977,7 @@ export class LlDyn { // function boundary (the dynFrom func special case, sans name). const p = emitUnionPeek(B, nullable, "%v"); const r = B.tmp(); - B.line(`${r} = call ptr @${this.dynFuncBoxHelper(arm)}(ptr ${p}, ptr null)`); + B.line(`${r} = ${this.unnamedFuncBoxCall(arm, p)}`); B.terminate(`ret ptr ${r}`); } else { const p = emitUnionPeek(B, nullable, "%v"); @@ -5359,9 +5364,8 @@ export class LlDyn { private toDynExpr(B: BlockBuilder, t: IrType, expr: string): string { if (t.kind === "dyn") return this.retainDyn(B, expr); if (t.kind === "func") { - const box = this.dynFuncBoxHelper(t); const r = B.tmp(); - B.line(`${r} = call ptr @${box}(ptr ${expr}, ptr null)`); + B.line(`${r} = ${this.unnamedFuncBoxCall(t, expr)}`); return r; } @@ -5651,6 +5655,98 @@ export class LlDyn { return name; } + private closureNames: { symbol: string; count: number } | null | undefined; + + /** The module's closure-name lookup (`sc_closure_name(closure)`, over a + * table of `{ ptr entry, ptr name }` pairs), defined on first use; null + * when no named function becomes a closure. */ + private closureNameTable(): { symbol: string; count: number } | null { + if (this.closureNames !== undefined) return this.closureNames; + const entries = this.host.closureNames?.() ?? []; + if (entries.length === 0) return (this.closureNames = null); + const host = this.host; + const S = host.sizeType; + const table = "sc_closure_names"; + const symbol = "sc_closure_name"; + // The closure's entry point (an adapter's root's, ScrClosure.identity) + // compared with each entry; null when none matches. + this.defs.push( + `@${table} = internal constant [${entries.length} x { ptr, ptr }] [${entries + .map((e) => `{ ptr, ptr } { ptr @${e.entry}, ptr ${host.cstr(e.name)} }`) + .join(", ")}]`, + ``, + `define internal ptr @${symbol}(ptr %v) ${FN_ATTRS} { ; the JavaScript name of a closure's function`, + `entry:`, + ` %idp = getelementptr inbounds %ScrClosure, ptr %v, i64 0, i32 6`, + ` %id = load ptr, ptr %idp`, + ` %own = icmp eq ptr %id, null`, + ` %root = select i1 %own, ptr %v, ptr %id`, + ` %fnp = getelementptr inbounds %ScrClosure, ptr %root, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ` br label %loop`, + `loop:`, + ` %i = phi ${S} [ 0, %entry ], [ %next, %step ]`, + ` %done = icmp eq ${S} %i, ${entries.length}`, + ` br i1 %done, label %none, label %test`, + `test:`, + ` %ep = getelementptr inbounds [${entries.length} x { ptr, ptr }], ptr @${table}, i64 0, ${S} %i, i32 0`, + ` %e = load ptr, ptr %ep`, + ` %hit = icmp eq ptr %e, %fn`, + ` br i1 %hit, label %found, label %step`, + `step:`, + ` %next = add ${S} %i, 1`, + ` br label %loop`, + `found:`, + ` %np = getelementptr inbounds [${entries.length} x { ptr, ptr }], ptr @${table}, i64 0, ${S} %i, i32 1`, + ` %name = load ptr, ptr %np`, + ` ret ptr %name`, + `none:`, + ` ret ptr null`, + `}`, + ``, + ); + return (this.closureNames = { symbol, count: entries.length }); + } + + private readonly unnamedFuncBoxes = new Map(); + + /** `sc_dfu_(ptr closure) -> ptr`: the box of a function value with no + * static name (one inside a record, array or other value being + * converted), named from the module's closure-name table. The plain box + * when no named function becomes a closure. */ + unnamedFuncBoxHelper(t: IrType & { kind: "func" }): string | null { + if ((this.host.closureNames?.() ?? []).length === 0) return null; + const box = this.dynFuncBoxHelper(t); + const key = typeKey(t); + const existing = this.unnamedFuncBoxes.get(key); + if (existing) return existing; + const name = `sc_dfu_${this.unnamedFuncBoxes.size}`; + this.unnamedFuncBoxes.set(key, name); + this.host.journalAllocated?.(name, "dyn.unnamedFuncBoxHelper", [t]); + // The first box defines the table and its lookup. + const table = this.closureNameTable()!; + this.defs.push( + `define internal ptr @${name}(ptr %v) ${FN_ATTRS} { ; box ${key} into dyn with its own name`, + `entry:`, + ` %n = call ptr @${table.symbol}(ptr %v)`, + ` %r = call ptr @${box}(ptr %v, ptr %n)`, + ` ret ptr %r`, + `}`, + ``, + ); + this.host.journalBuilt?.(); + return name; + } + + /** The call that boxes the function value `value` of type `t` without a + * static name (unnamedFuncBoxHelper). */ + unnamedFuncBoxCall(t: IrType & { kind: "func" }, value: string): string { + const named = this.unnamedFuncBoxHelper(t); + return named === null + ? `call ptr @${this.dynFuncBoxHelper(t)}(ptr ${value}, ptr null)` + : `call ptr @${named}(ptr ${value})`; + } + /** The box builder dynFrom emits for one closure signature. */ dynFuncBoxHelper(t: IrType & { kind: "func" }): string { const key = typeKey(t); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8bee15a41..52ae3bf07 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -924,6 +924,7 @@ const POLL_EVERY_ITERATION = "%worker.poll.every"; /** Runtime library calls (readsLoopMetrics). */ const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"]; +const CLOSURE_KINDS: readonly IrNodeKind[] = ["closure"]; /** [A-Za-z0-9_] */ function isIdentifierByte(code: number): boolean { @@ -2465,6 +2466,7 @@ export class LlEmitter { ); }, pendingTestLines: (dest) => this.pendingTestLines(dest), + closureNames: () => this.closureNames(), virtualEntry: (implFn) => this.virtualEntry(implFn), programLifetime: this.programLifetime, regionAliasTag: (word) => this.memoryAliasAttachment(`region.${word}`), @@ -2808,6 +2810,33 @@ export class LlEmitter { return this.heapCensus === null ? out : [...out, this.heapCensus.definitions().join("\n")]; } + /** The entry point and JavaScript name of each named function the + * module makes closures of: a declared function's wrapper (its interned + * closure's entry) or a lifted function's call target, with its + * displayName, else its sourceName. Function boxes without a static name + * look the closure's entry up in this table. */ + private closureNameEntries: readonly { entry: string; name: string }[] | undefined; + closureNames(): readonly { entry: string; name: string }[] { + if (this.closureNameEntries !== undefined) return this.closureNameEntries; + const entries = new Map(); + const scan = this.nodeIndex.scan(CLOSURE_KINDS); + for (const fn of this.mod.functions) { + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind !== "closure") return true; + const target = this.fnByName.get(node.fnName); + const name = + target?.displayName ?? + (target?.sourceName !== "" ? target?.sourceName : undefined); + if (target === undefined || name === undefined || name === "") return true; + const entry = + target.captures === undefined ? mangleWrapper(node.fnName) : this.callTarget(node.fnName); + if (!entries.has(entry)) entries.set(entry, name); + return true; + }); + } + return (this.closureNameEntries = [...entries].map(([entry, name]) => ({ entry, name }))); + } + /** The dispose-observer gates the emitted frees and record writers test * (alloc.ts): defined after every helper is emitted, because dynCheck * builders, JSON schemas and weak collection constructors are requested @@ -8598,6 +8627,7 @@ for (const op of [ "dyn.dynFuncThunkHelper", "dyn.dynFuncBoxHelper", "dyn.jsEntryThunk", + "dyn.unnamedFuncBoxHelper", "dyn.dynFuncAdapterHelper", "dyn.dynToStrHelper", "dyn.dynIterNHelper", diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index d977818f1..8f34b7d5e 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -120,10 +120,12 @@ export function emitDynamicExpr( // A closure boxes as the checked-dynamic tree's function kind: retained closure + // the per-signature call thunk + the interned signature key. The // best-effort name rides along (null when the lowering had none). - const name = e.fnName !== undefined && e.fnName !== "" ? host.cstr(e.fnName) : "null"; - const box = host.dyn.dynFuncBoxHelper(v.type); const t = B.tmp(); - B.line(`${t} = call ptr @${box}(ptr ${v.name}, ptr ${name})`); + if (e.fnName !== undefined && e.fnName !== "") { + const name = host.cstr(e.fnName); + const box = host.dyn.dynFuncBoxHelper(v.type); + B.line(`${t} = call ptr @${box}(ptr ${v.name}, ptr ${name})`); + } else B.line(`${t} = ${host.dyn.unnamedFuncBoxCall(v.type, v.name)}`); if (e.fnLength !== undefined) { host.declare(`declare void @scr_dyn_function_set_length(ptr, i32)`); B.line(`call void @scr_dyn_function_set_length(ptr ${t}, i32 ${e.fnLength})`); diff --git a/packages/compiler/src/frontend/lowering/function-names.ts b/packages/compiler/src/frontend/lowering/function-names.ts new file mode 100644 index 000000000..bbee161cc --- /dev/null +++ b/packages/compiler/src/frontend/lowering/function-names.ts @@ -0,0 +1,99 @@ +/* The JavaScript names of object literals' methods. + * + * A function boxed into a checked value inside a record, an array or + * another converted value has no static name at the box, so the backend + * takes it from the function (LlEmitter.closureNames): its sourceName, or + * for an object literal's method, whose stack-frame name differs, its + * displayName. displayName is set only in modules that box such a + * function, so other modules lower as before. */ +import * as ts from "../ts7/adapter.js"; +import type { IrFunction, IrType } from "../../ir/ir.js"; +import { typeKey } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Notes the name of the lifted function `fnName` when `node` is a method + * of an object literal with a static key. */ +export function noteObjectMethodName(lowerer: Lowerer, node: ts.Node, fnName: string): void { + if (!ts.isMethodDeclaration(node) || !ts.isObjectLiteralExpression(node.parent)) return; + const name = node.name; + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) + lowerer.objectMethodNames.set(fnName, name.text); + else if (ts.isNumericLiteral(name)) + lowerer.objectMethodNames.set(fnName, String(Number(name.text))); +} + +/** Gives the noted methods their displayName when the module boxes a + * function without a static name, or a value that can hold one. */ +export function finalizeFunctionNames(lowerer: Lowerer, functions: readonly IrFunction[]): void { + if (lowerer.objectMethodNames.size === 0) return; + const seen = new Map(); + let boxes = false; + for (const fn of functions) { + everyStmtList(fn.body, { + stmt: () => !boxes, + expr: (e) => { + // A function boxed on its own with a static name needs none. + if ( + e.kind === "dynFrom" && + (e.value.type.kind === "func" + ? e.fnName === undefined || e.fnName === "" + : reachesFunction(lowerer, e.value.type, seen)) + ) + boxes = true; + return !boxes; + }, + }); + if (boxes) break; + } + if (!boxes) return; + for (const fn of functions) { + const name = lowerer.objectMethodNames.get(fn.name); + if (name !== undefined) fn.displayName = name; + } +} + +/** Whether a value of `t` can hold a function value. */ +function reachesFunction(lowerer: Lowerer, t: IrType, seen: Map): boolean { + const key = typeKey(t); + const known = seen.get(key); + if (known !== undefined) return known; + seen.set(key, false); + let result = false; + switch (t.kind) { + case "func": + result = true; + break; + case "record": { + const shape = lowerer.shapes.get(t.shapeId); + result = + (shape?.fields.some((field) => reachesFunction(lowerer, field.type, seen)) ?? false) || + (shape?.indexValue !== undefined && reachesFunction(lowerer, shape.indexValue, seen)); + break; + } + case "array": + case "set": + result = reachesFunction(lowerer, t.elem, seen); + break; + case "map": + result = reachesFunction(lowerer, t.key, seen) || reachesFunction(lowerer, t.value, seen); + break; + case "union": + result = (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => + reachesFunction(lowerer, arm, seen), + ); + break; + case "promise": + result = reachesFunction(lowerer, t.inner, seen); + break; + case "object": + result = [...(lowerer.classes.get(t.className)?.fields.values() ?? [])].some((field) => + reachesFunction(lowerer, field, seen), + ); + break; + default: + result = false; + } + seen.set(key, result); + return result; +} diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 48cdfa9cd..26c002b87 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -40,6 +40,7 @@ import { import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; import { isStrictCode } from "./strict-mode.js"; import { lowerSloppyGlobalReceiver } from "./lower-global-value.js"; +import { noteObjectMethodName } from "./function-names.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -550,6 +551,7 @@ export function lowerLambda( if (isAsync) lifted.async = true; if (fnCtx.generator) lifted.generator = fnCtx.generator; lowerer.liftedFns.push(lifted); + noteObjectMethodName(lowerer, node, fnName); return { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc }; } catch (e) { // JS sources defer LAMBDA poisons like function declarations diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a29f59853..6b154d9f8 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -249,6 +249,7 @@ import { import { DeferredModuleInitializers } from "./deferred-module-initializers.js"; import { finalizeClassMethodValues } from "./class-method-values.js"; import { finalizePrivateBrands } from "./private-brands.js"; +import { finalizeFunctionNames } from "./function-names.js"; import { type ClassSymbolKey, INSPECT_CUSTOM_MEMBER, tableKeyFieldType } from "./symbol-fields.js"; import type { HttpClientFnBinding } from "./lower-server.js"; export type { WidthLift } from "./width-lift.js"; @@ -3826,6 +3827,9 @@ export class Lowerer { >(); /** Classes used as ThreadSlots keys: their hierarchies carry a slot word. */ readonly threadSlotKeys = new Set(); + /** The JavaScript names of object literals' methods, by lifted function + * (function-names.ts). */ + readonly objectMethodNames = new Map(); readonly forkTargetIdByPath = new Map(); /** Namespace ModuleBlocks this program lowers, filled by splitFiles: * "flattened" — an instantiated namespace whose body joined the file's @@ -4447,6 +4451,7 @@ export class Lowerer { checkedTable("threadSlotNames", () => this.threadSlotNames.size), checkedTable("threadSlotFamilies", () => this.threadSlotFamilies.size), checkedTable("threadSlotKeys", () => this.threadSlotKeys.size), + checkedTable("objectMethodNames", () => this.objectMethodNames.size), checkedTable("forkTargetIdByPath", () => this.forkTargetIdByPath.size), checkedTable("nsBlocks", () => this.nsBlocks.size), checkedTable("initNameOf", () => this.initNameOf.size), @@ -9816,6 +9821,7 @@ export class Lowerer { // one is exact only if the declaration is held at a single signature. checkGenericIdentityPins(this); boxBuiltinValuesChecked(this, functions); + finalizeFunctionNames(this, functions); if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index b3d15dfae..911cb5065 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "c8e9ba1a465defc2"; +export const IR_CODEC_LAYOUT = "23df825e49b75d14"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -25,7 +25,7 @@ export function decodeIrFunction(r: IrReader): IrFunction { } const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"]; -const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","jsEntry","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; +const KEYS1: readonly string[] = ["name","sourceName","displayName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","jsEntry","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"]; const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"]; const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"]; @@ -404,6 +404,11 @@ function encIrFunction(w: IrWriter, v: IrFunction): void { w.byte(1); w.str(v.sourceName); } + if (v.displayName === undefined) w.byte(0); + else { + w.byte(1); + w.str(v.displayName); + } if (v.ownsPrototype === undefined) w.byte(0); else { w.byte(1); @@ -473,24 +478,25 @@ function encIrFunction(w: IrWriter, v: IrFunction): void { function decIrFunction(r: IrReader): IrFunction { const f0 = r.str(); const f1 = r.tag(2, "IrFunction.sourceName") === 0 ? undefined : r.str(); - const f2 = r.tag(2, "IrFunction.ownsPrototype") === 0 ? undefined : (r.bool() as NonNullable); - const f3 = decT15(r); - const f4 = decIrType(r); - const f5 = decT17(r); - const f6 = r.tag(2, "IrFunction.captures") === 0 ? undefined : decT15(r); - const f7 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r); - const f8 = decT19(r); - const f9 = decSrcLoc(r); - const f10 = r.tag(2, "IrFunction.jsEntry") === 0 ? undefined : r.str(); - const f11 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); - const f12 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); - const f13 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); - const f14 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable); - const f15 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); - const f16 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); - const f17 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); - const f18 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); - const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { jsEntry: f10 } : {}), ...(f11 !== undefined ? { tier: f11 } : {}), ...(f12 !== undefined ? { speculativeDispatch: f12 } : {}), ...(f13 !== undefined ? { fastRead: f13 } : {}), ...(f14 !== undefined ? { plainLoops: f14 } : {}), ...(f15 !== undefined ? { async: f15 } : {}), ...(f16 !== undefined ? { generator: f16 } : {}), ...(f17 !== undefined ? { asyncCacheGlobal: f17 } : {}), ...(f18 !== undefined ? { asyncCycleCacheGlobal: f18 } : {}) }; + const f2 = r.tag(2, "IrFunction.displayName") === 0 ? undefined : r.str(); + const f3 = r.tag(2, "IrFunction.ownsPrototype") === 0 ? undefined : (r.bool() as NonNullable); + const f4 = decT15(r); + const f5 = decIrType(r); + const f6 = decT17(r); + const f7 = r.tag(2, "IrFunction.captures") === 0 ? undefined : decT15(r); + const f8 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r); + const f9 = decT19(r); + const f10 = decSrcLoc(r); + const f11 = r.tag(2, "IrFunction.jsEntry") === 0 ? undefined : r.str(); + const f12 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); + const f13 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); + const f14 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); + const f15 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable); + const f16 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); + const f17 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); + const f18 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); + const f19 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); + const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { displayName: f2 } : {}), ...(f3 !== undefined ? { ownsPrototype: f3 } : {}), params: f4, returnType: f5, locals: f6, ...(f7 !== undefined ? { captures: f7 } : {}), ...(f8 !== undefined ? { classCaptures: f8 } : {}), body: f9, loc: f10, ...(f11 !== undefined ? { jsEntry: f11 } : {}), ...(f12 !== undefined ? { tier: f12 } : {}), ...(f13 !== undefined ? { speculativeDispatch: f13 } : {}), ...(f14 !== undefined ? { fastRead: f14 } : {}), ...(f15 !== undefined ? { plainLoops: f15 } : {}), ...(f16 !== undefined ? { async: f16 } : {}), ...(f17 !== undefined ? { generator: f17 } : {}), ...(f18 !== undefined ? { asyncCacheGlobal: f18 } : {}), ...(f19 !== undefined ? { asyncCycleCacheGlobal: f19 } : {}) }; return o; } diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index b5f2cadae..b331f5ce4 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -135,6 +135,7 @@ const FUNCTION_FIELDS: Record>, Role> = { name: "other", sourceName: "other", jsEntry: "reference", + displayName: "other", asyncCacheGlobal: "other", asyncCycleCacheGlobal: "other", "params[].name": "other", diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index dfa266c80..637bd3a60 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1966,6 +1966,10 @@ export interface IrFunction { name: string; /** Source function name for native error stack frames. */ sourceName?: string; + /** The function's JavaScript `name` when it differs from sourceName: an + * object literal's method. Present only in modules that box function + * values into checked values (function-names.ts). */ + displayName?: string; /** Ordinary authored function declarations/expressions own a prototype. */ ownsPrototype?: true; params: IrParam[]; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/boxed-function-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/boxed-function-names.ts.json new file mode 100644 index 000000000..dc0658285 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/boxed-function-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/boxed-function-names.ts" + ], + "diags": [] +} diff --git a/tests/corpus/boxed-function-names.ts b/tests/corpus/boxed-function-names.ts new file mode 100644 index 000000000..0375fac7b --- /dev/null +++ b/tests/corpus/boxed-function-names.ts @@ -0,0 +1,30 @@ +// Functions held in records, arrays and maps keep their JavaScript names +// when the value is converted to unknown: util.inspect prints them. +function declared() { + return 0; +} +const named = function inner() { + return 1; +}; +const arrow = () => 2; +const wide = { + a: 1, + toString() { + return "w"; + }, + h: () => 3, + g: named, + d: declared, + "quoted"() { + return 4; + }, +}; +const box: unknown = wide; +console.log(box); +const list: unknown = [declared, named, arrow]; +console.log(list); +const byName = new Map number>([["a", arrow]]); +const mapBox: unknown = byName; +console.log(mapBox); +const plain: unknown = { f: function () { return 5; } }; +console.log(plain); diff --git a/tests/harness/corpus-costs/boxed-function-names.ts.json b/tests/harness/corpus-costs/boxed-function-names.ts.json new file mode 100644 index 000000000..151bf487b --- /dev/null +++ b/tests/harness/corpus-costs/boxed-function-names.ts.json @@ -0,0 +1 @@ +{ "plain": 1946, "san": 3000 }