From f521dc91b1a4c05db657dcc59b89150c803bdb0c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:51:09 -0500 Subject: [PATCH 1/2] Compile three Node test shapes: a top-level return before declarations, nullable strictEqual operands, and a branch-assigned let A top-level return in a CommonJS module compiles when declarations follow it. When the return is taken, only code that already exists can observe the bindings it skips: functions and classes created before the return, and function declarations after it. A module where one of them reads a binding declared after the return, or that assigns exports after it, still stops with SC1090. assert.strictEqual and assert.notStrictEqual compare a nullable number, string or boolean (`number | null`, `string | null`) with a scalar, null or undefined through the checked-dynamic SameValue compare, which reports Node's failure messages. notStrictEqual of another nullable value, such as `child.stdin`, against null or undefined tests the value's tag. An initializer-less JavaScript `let` whose writes all agree also takes their type when the writes are calls declared in the standard library, such as `spawn(...)`, and at file scope. A runtime handle that a closure captures takes the handle or undefined. Inside a nested function, a member read on it (`c.address()`, `child.stdin`) reads the handle and throws Node's TypeError while the binding is still undefined; other reads there take the checked-dynamic value. --- .../limitations/builtin-apis/assertions.mdx | 2 + .../language-features/commonjs-require.mdx | 2 + .../lowering/expressions/identifiers.ts | 55 ++++++ .../src/frontend/lowering/lower-assert.ts | 79 ++++++++- .../src/frontend/lowering/lower-modules.ts | 11 +- .../src/frontend/lowering/lower-stmts.ts | 165 ++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 4 + .../assert-strict-equal-nullable.ts.json | 6 + ...s-top-level-return-later-bindings.cjs.json | 6 + .../evolving-let-child-process.cjs.json | 6 + tests/corpus/assert-strict-equal-nullable.ts | 72 ++++++++ .../cjs-top-level-return-later-bindings.cjs | 28 +++ tests/corpus/evolving-let-child-process.cjs | 46 +++++ tests/diagnostics/assert-fences.ts | 8 +- .../__snapshots__/assert-fences.ts.txt | 4 +- .../assert-strict-equal-nullable.ts.json | 1 + ...s-top-level-return-later-bindings.cjs.json | 1 + .../evolving-let-child-process.cjs.json | 1 + 18 files changed, 456 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json create mode 100644 tests/corpus/assert-strict-equal-nullable.ts create mode 100644 tests/corpus/cjs-top-level-return-later-bindings.cjs create mode 100644 tests/corpus/evolving-let-child-process.cjs create mode 100644 tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json create mode 100644 tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json create mode 100644 tests/harness/corpus-costs/evolving-let-child-process.cjs.json diff --git a/docs/content/limitations/builtin-apis/assertions.mdx b/docs/content/limitations/builtin-apis/assertions.mdx index 381fc3802..84c830e79 100644 --- a/docs/content/limitations/builtin-apis/assertions.mdx +++ b/docs/content/limitations/builtin-apis/assertions.mdx @@ -5,6 +5,8 @@ `assert.AssertionError` used as a value outside `instanceof` throws `SC2020`. The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument. `throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`. + `strictEqual` and `notStrictEqual` compare a number, string or boolean that can be `null` or `undefined`, such as `child.signalCode`, with any number, string, boolean, `null` or `undefined`. + `notStrictEqual` of another value that can be `null` or `undefined`, such as `child.stdin`, against `null` or `undefined` compiles; `strictEqual` of one throws `SC2020`. `assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused. `assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals. Checked-dynamic values compared deeply have no cycle detection. diff --git a/docs/content/limitations/language-features/commonjs-require.mdx b/docs/content/limitations/language-features/commonjs-require.mdx index f0bf67c4e..f13ca143f 100644 --- a/docs/content/limitations/language-features/commonjs-require.mdx +++ b/docs/content/limitations/language-features/commonjs-require.mdx @@ -7,3 +7,5 @@ Defaults, rest elements, and nested patterns over other modules are unsupported. A require cycle compiles when each of its modules assigns all of its exports before its first `require` and uses the other modules of the cycle only inside functions that its top-level code can't call. Other require cycles are rejected with `SC1016`. + A top-level `return` ends the module's evaluation. + It is unsupported when the module assigns exports after it, or when a function that exists before it runs reads a binding declared after it, such as a callback created above the `return` or a function declaration. diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index a790f57a6..eb3f7e7aa 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -192,6 +192,11 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // when the checker types this USE as a single arm, maybeNarrow // bridges the tagged representation with a unionNarrow. const local = lowerer.resolveLocal(expr); + { + const slot = local ?? lowerer.globalOf(expr); + const closureRead = slot ? closureDynRead(lowerer, expr, slot.id, slot.type, loc) : null; + if (closureRead) return closureRead; + } // A Date local holding its time value reads as that Date: only uses // that read the time value reach here (date-values.ts). if (local && lowerer.timeValueDateLocals.has(local)) { @@ -1218,3 +1223,53 @@ export function uriFunctionValue(lowerer: Lowerer, which: string, loc: SrcLoc): } return { kind: "closure", fnName: name, captures: [], type, loc }; } + +/** A read of a captured handle `let` (Lowerer.closureDynLets) inside a + * function nested in the binding's scope. The checker types it as the + * handle, but the closure can run before the first write. As a member + * receiver (`c.address()`, `child.stdin`) it reads the handle and throws + * Node's TypeError when the binding is still undefined; elsewhere it reads + * the checked-dynamic value of the handle or undefined. Null for other + * reads. */ +function closureDynRead( + lowerer: Lowerer, + expr: ts.Identifier, + id: string, + type: IrType, + loc: SrcLoc, +): IrExpr | null { + if (type.kind !== "union" || lowerer.closureDynLets.size === 0) return null; + const symbol = lowerer.resolveValueSymbol(expr); + if (!symbol || !lowerer.closureDynLets.has(symbol)) return null; + const decl = symbol.valueDeclaration; + if (!decl) return null; + const scopeOf = (node: ts.Node): ts.Node | undefined => { + let at = node.parent; + while (at !== undefined && !ts.isFunctionLike(at) && !ts.isSourceFile(at)) at = at.parent; + return at; + }; + if (scopeOf(expr) === scopeOf(decl)) return null; + const parent = expr.parent; + const handle = lowerer.unions.get(type.unionId)?.arms.find((arm) => !isUnitType(arm)); + if (handle && ts.isPropertyAccessExpression(parent) && parent.expression === expr) { + const helper = lowerer.narrowedArmHelper( + type.unionId, + handle, + loc, + `Cannot read properties of undefined (reading '${parent.name.text}')`, + ); + if (helper) + return lowerer.checkedNarrowCall( + helper, + { kind: "varRef", localId: id, type, loc }, + handle, + loc, + ); + } + return { + kind: "dynFrom", + value: { kind: "varRef", localId: id, type, loc }, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index 704314eaf..d167bb036 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1290,12 +1290,28 @@ function lowerAssertEqual( ?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ?? false))) && lowerer.dynConvertible(e.type); + // A nullable number, string or boolean (`number | null`) against a + // scalar or null/undefined: under the strict pair each arm crosses by + // value, so the checked-dynamic SameValue compare answers, and renders + // its failure message, as Node does. + const strictArm = (t: IrType): boolean => + t.kind === "f64" || t.kind === "string" || t.kind === "bool" || isUnitType(t); + const strictOperand = (e: IrExpr): boolean => + !deep && + (e.kind === "unitLit" || + strictArm(e.type) || + (e.type.kind === "union" && + (lowerer.unions.get(e.type.unionId)?.arms.every(strictArm) ?? false))) && + lowerer.dynConvertible(e.type); + const nullable = (e: IrExpr): boolean => e.type.kind === "union" || isUnitType(e.type); + const strictNullable = (nullable(a) || nullable(b)) && strictOperand(a) && strictOperand(b); if ( a.type.kind === "dyn" || b.type.kind === "dyn" || a.type.kind === "moduleNs" || b.type.kind === "moduleNs" || - (instance(a) && instance(b)) + (instance(a) && instance(b)) || + strictNullable ) { const box = (e: IrExpr, node: ts.Expression): IrExpr => { if (e.type.kind === "dyn") return e; @@ -1339,6 +1355,7 @@ function lowerAssertEqual( // re-encoding and losing identity (SEMANTICS.md). (k === "object" && e.type.kind === "object" && e.type.className === "%Error") || instance(e) || + strictOperand(e) || // A symbol crosses as itself, so strict compares are identity. (!deep && k === "symbol") || (deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) || @@ -1472,6 +1489,66 @@ function lowerAssertEqual( loc, }; } + // notStrictEqual of a nullable value whose other arms do not convert + // (`child.stdin`, a stream or null) against null or undefined fails only + // when the value is that unit, and the failure message renders only the + // unit. The value's tag test stands in for it: the unit when it matches, + // `false` otherwise, which the unit never equals. A union without that + // unit arm never equals it. + if (!deep && negated) { + const unitSide = isUnitType(a.type) ? a : isUnitType(b.type) ? b : null; + const other = unitSide === a ? b : a; + if (unitSide !== null && other.type.kind === "union") { + const tag = lowerer.armTag(other.type.unionId, unitSide.type); + const unit = unitSide.type.kind === "nullT" ? "null" : "undefined"; + const unequal: IrExpr = { kind: "dynFrom", value: boolLit(false, loc), type: DYN, loc }; + const tested: IrExpr = + tag < 0 + ? { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: other, loc }], + result: unequal, + type: DYN, + loc, + } + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: other.type.unionId, + tag, + negated: false, + value: other, + type: BOOL, + loc, + }, + then: { + kind: "dynFrom", + value: { kind: "unitLit", unit, type: unitSide.type, loc }, + type: DYN, + loc, + }, + else_: unequal, + type: DYN, + loc, + }; + const unitDyn: IrExpr = { kind: "dynFrom", value: unitSide, type: DYN, loc: unitSide.loc }; + return { + kind: "libCall", + fn: "assert.eqDyn", + args: [ + unitSide === a ? unitDyn : tested, + unitSide === a ? tested : unitDyn, + boolLit(true, loc), + boolLit(false, loc), + msg, + hasMsg, + ], + type: VOID, + loc, + }; + } + } if (!deep) { const hint = a.type.kind === "union" || b.type.kind === "union" diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce58..6ebad7275 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -131,6 +131,7 @@ import { funcTypeFromParamShapes, registerOverloadedCallableAlias } from "./call import { jsBindingHasOpenWrites, hasJsTypeAnnotation, + inferredEvolvingLetType, isVarDeclared, numericIteratorSourceOf, provenanceElidedConstDecl, @@ -2438,10 +2439,16 @@ export function collectGlobals( ) { const symbol = lowerer.checker.getSymbolAtLocation(nameNode); if (symbol && !lowerer.globalsBySymbol.has(symbol)) { + // An initializer-less `let` whose writes all agree keeps + // their type, as a function's local does (lowerVarDecl). + const evolved = + isLet && !isVarDeclared(decl) && decl.initializer === undefined + ? inferredEvolvingLetType(lowerer, decl) + : null; const g: IrGlobal = { id: `%g.${tag}${nsPrefix}${nameNode.text}`, name: nameNode.text, - type: DYN, + type: evolved ?? DYN, mutable: isLet, source: bindingSource(nameNode), }; @@ -2450,7 +2457,7 @@ export function collectGlobals( // Mutable dyn globals hold the dyn undefined from module // entry (the main path's rule below): a closure called // above the declaration reads undefined instead of - // faulting on NULL. + // faulting on NULL. An undefined-armed union does the same. if (g.mutable) noteVarGlobalEntryInit(lowerer, sf, g); } continue; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 8231365b1..3332807a4 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -98,6 +98,7 @@ import { BYTES_U8, CAUGHT, DATE_T, + DYN_HANDLE_KINDS, DYN, F64, type IrExpr, @@ -1765,10 +1766,12 @@ const THROWN_AS_DYN: ReadonlySet = new Set([ /** A `return` at the top level of a CommonJS module (preflight admits it * there only): it ends the module's evaluation, and its value is - * evaluated and discarded, as Node's module wrapper does. Top-level - * bindings and export assignments after it would be left uninitialized - * where Node leaves them in their temporal dead zone or absent, so a - * module with any keeps a refusal. Null for a return inside a function. */ + * evaluated and discarded, as Node's module wrapper does. Bindings + * declared after it stay uninitialized when it is taken, which only code + * that exists by then can observe: a function created before the return, + * or a hoisted function declaration. A module whose such functions read a + * later binding, or that assigns exports after the return, keeps a + * refusal. Null for a return inside a function. */ function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null { let top: ts.Node = stmt; while (top.parent !== undefined && !ts.isSourceFile(top.parent)) { @@ -1778,19 +1781,34 @@ function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrS const sf = top.parent; if (sf === undefined || !ts.isSourceFile(sf)) return null; const stmts = sf.statements.nodes; - const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1); - if ( - later.some( - (s) => - ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null, - ) - ) { + const index = stmts.indexOf(top as ts.Statement); + const later = stmts.slice(index + 1); + if (later.some((s) => cjsExportAssignmentOf(s) !== null)) { lowerer.unsupported( "SC1090", stmt, - "a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it", + "a top-level return in a CommonJS module that assigns exports after it", ); } + const laterBindings = laterModuleBindings(lowerer, later); + if (laterBindings.size > 0) { + // Functions that exist when the return is taken: every function in the + // statements up to the return, and the hoisted declarations after it. + const reachable = [ + ...stmts.slice(0, index + 1), + ...later.filter((s) => ts.isFunctionDeclaration(s)), + ]; + const read = reachable + .map((s) => functionReadOf(lowerer, s, laterBindings, ts.isFunctionDeclaration(s))) + .find((r) => r !== null); + if (read) { + lowerer.unsupported( + "SC1090", + read, + `reading '${read.text}', declared after a top-level CommonJS return, from a function that can run once the return is taken`, + ); + } + } const loc = locOf(stmt); const ret: IrStmt = { kind: "return", value: null, loc }; if (!stmt.expression) return ret; @@ -1801,6 +1819,57 @@ function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrS }; } +/** The module-scope bindings `stmts` declare: variables and classes, with + * `var` declarations in nested blocks. Bindings inside functions are not + * module-scope and are skipped. */ +function laterModuleBindings(lowerer: Lowerer, stmts: readonly ts.Statement[]): Set { + const bindings = new Set(); + const addName = (name: ts.BindingName): void => { + if (ts.isIdentifier(name)) { + const symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol) bindings.add(symbol); + return; + } + for (const element of name.elements.nodes) { + if (ts.isBindingElement(element) && element.name) addName(element.name); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassStaticBlockDeclaration(node)) return; + if (ts.isVariableDeclaration(node)) addName(node.name); + if (ts.isClassDeclaration(node) && node.name) addName(node.name); + ts.forEachChild(node, visit); + }; + for (const stmt of stmts) visit(stmt); + return bindings; +} + +/** The first identifier inside a function or class in `node` (anywhere in + * `node` when `inFunction`) that reads one of `bindings`, or null. */ +function functionReadOf( + lowerer: Lowerer, + node: ts.Node, + bindings: ReadonlySet, + inFunction: boolean, +): ts.Identifier | null { + if (inFunction && ts.isIdentifier(node)) { + const symbol = lowerer.resolveValueSymbol(node); + return symbol && bindings.has(symbol) ? node : null; + } + // Class bodies count as functions: field initializers run per instance. + const nested = + inFunction || + ts.isFunctionLike(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node); + return ( + ts.forEachChild( + node, + (child) => functionReadOf(lowerer, child, bindings, nested) ?? undefined, + ) ?? null + ); +} + export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null { if (ownPrototypeResetStatement(lowerer, stmt)) return { kind: "block", body: [], loc: locOf(stmt) }; @@ -5917,15 +5986,24 @@ function evolvingArrayHolderType(lowerer: Lowerer, decl: ts.VariableDeclaration) /** A single static type for an initializer-less JavaScript `let` whose * later plain writes all agree. An evolving JS local reads as `undefined` * until control flow proves a write, so a scalar slot is sound only when - * every ordinary use already has that concrete flow type. Any closure - * capture stays checked-dynamic: it can observe the local before an outer - * assignment, and the checker deliberately leaves captured evolving lets - * broad. */ -function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null { + * every ordinary use already has that concrete flow type. A write's call + * counts when its signature is declared in the standard library, whose + * return types are declared rather than inferred from JavaScript. A + * closure capture can observe the local before an outer assignment, and + * the checker leaves captured evolving lets broad: a captured runtime + * handle (`let child; if (...) child = spawn(...)`) takes the handle or + * `undefined`, which nested functions read as a checked-dynamic value + * (closureDynLets), and other captured bindings stay checked-dynamic. */ +export function inferredEvolvingLetType( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): IrType | null { if (!ts.isIdentifier(decl.name)) return null; const symbol = lowerer.checker.getSymbolAtLocation(decl.name); const scope = decl.parent?.parent?.parent; if (symbol === undefined || (!ts.isBlock(scope) && !ts.isSourceFile(scope))) return null; + // Another module reads an exported binding, which this scan does not see. + if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) return null; // A shorthand property's name (`{ width }`) resolves to the property // symbol, but it reads the binding: the checker's shorthand-value symbol. @@ -5960,9 +6038,19 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) const writes: IrType[] = []; const reads: ts.Identifier[] = []; let invalid = false; - ts.walkPreorder(scope, (node) => { - if (node !== scope && ts.isFunctionLike(node)) { - if (captures(node)) invalid = true; + let captured = false; + // The capturing closure being scanned: reads inside one carry the + // checker's broad type, so only its writes are collected. + let closure: ts.Node | null = null; + const visit = (node: ts.Node): void | "skip" | "stop" => { + if (node !== scope && node !== closure && ts.isFunctionLike(node)) { + if (captures(node)) { + captured = true; + const outer = closure; + closure = node; + ts.walkPreorder(node, visit); + closure = outer; + } return "skip"; } if (ts.isBinaryExpression(node)) { @@ -5973,11 +6061,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) invalid = true; return "stop"; } - if ( - ts.isCallExpression(node.right) && - (implicitMethodCallInfersReturn(lowerer, node.right) || - isJsSourceFile(ts.getSourceFileOfNode(node.right))) - ) { + if (ts.isCallExpression(node.right) && !stdlibDeclaredCall(lowerer, node.right)) { invalid = true; return "stop"; } @@ -6006,6 +6090,7 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) return "stop"; } if ( + closure === null && ts.isIdentifier(node) && node !== decl.name && refersToBinding(node) && @@ -6014,16 +6099,32 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration) reads.push(node); } return undefined; - }); + }; + ts.walkPreorder(scope, visit); const first = writes[0]; if (invalid || first === undefined || !writes.every((write) => typeEquals(write, first))) return null; - return reads.every((read) => { - const mapped = lowerer.mapTypeOf(lowerer.typeOf(read)); - return mapped !== null && typeEquals(mapped, first); - }) - ? first - : null; + if ( + !reads.every((read) => { + const mapped = lowerer.mapTypeOf(lowerer.typeOf(read)); + return mapped !== null && typeEquals(mapped, first); + }) + ) + return null; + if (!captured) return first; + if (!DYN_HANDLE_KINDS.has(first.kind)) return null; + lowerer.closureDynLets.add(symbol); + return { kind: "union", unionId: lowerer.unions.intern([first, UNDEFINED_T]) }; +} + +/** Whether a call's resolved signature is declared in the standard library + * (`spawn(...)`), so its declared return type is the value's type. Calls of + * JavaScript functions, and implicit generic methods, infer theirs, and a + * `require(...)` result is a module rather than a value of its type. */ +function stdlibDeclaredCall(lowerer: Lowerer, call: ts.CallExpression): boolean { + if (implicitMethodCallInfersReturn(lowerer, call) || requireSpecOf(call) !== null) return false; + const declaration = lowerer.checker.getResolvedSignature(call)?.declaration; + return declaration !== undefined && lowerer.isStdlibFile(ts.getSourceFileOfNode(declaration)); } export function lowerVarDecl( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ef9d5c31c..0b73bc0e4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3993,6 +3993,10 @@ export class Lowerer { * function called above the declaration statement reads that, never a * NULL slot. Filled by collectGlobals. */ readonly varGlobalEntryInits = new Map(); + /** Initializer-less JavaScript lets holding a runtime handle or undefined + * that a closure captures (inferredEvolvingLetType). Reads inside a nested + * function lower as the checked-dynamic value. */ + readonly closureDynLets = new Set(); /** Import-cycle bindings with an initialization flag, by global id, and * the top-level statements declaring them (cycle-initialization.ts). */ readonly cycleInitFlags = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json new file mode 100644 index 000000000..92d5e4440 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/assert-strict-equal-nullable.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/assert-strict-equal-nullable.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json new file mode 100644 index 000000000..938628e19 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/cjs-top-level-return-later-bindings.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/cjs-top-level-return-later-bindings.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json new file mode 100644 index 000000000..9ab5efe31 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-child-process.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/evolving-let-child-process.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/assert-strict-equal-nullable.ts b/tests/corpus/assert-strict-equal-nullable.ts new file mode 100644 index 000000000..0a9051c56 --- /dev/null +++ b/tests/corpus/assert-strict-equal-nullable.ts @@ -0,0 +1,72 @@ +// assert.strictEqual and assert.notStrictEqual compare a nullable number, +// string or boolean with SameValue against a value of another type or +// null/undefined, and notStrictEqual compares a nullable stream against +// null or undefined. Failures report Node's messages. +import assert from "node:assert"; +import { spawn, spawnSync } from "node:child_process"; + +function find(values: number[], value: number): number | null { + const index = values.indexOf(value); + return index < 0 ? null : index; +} +function label(n: number): string | null { + return n > 0 ? "positive" : null; +} +function ratio(n: number): number | undefined { + return n === 0 ? undefined : n / n; +} +function flag(n: number): boolean | null { + return n === 0 ? null : n > 0; +} + +assert.strictEqual(find([1, 2, 3], 2), 1); +assert.strictEqual(find([1, 2, 3], 9), null); +assert.notStrictEqual(find([1, 2, 3], 3), null); +assert.notStrictEqual(find([1, 2, 3], 3), 1); +assert.strictEqual(label(1), "positive"); +assert.strictEqual(label(-1), null); +assert.notStrictEqual(label(-1), undefined); +assert.strictEqual(ratio(0), undefined); +assert.strictEqual(ratio(-0), undefined); +assert.strictEqual(flag(2), true); +assert.strictEqual(flag(0), null); +assert.strictEqual(null, find([], 1)); +console.log("passing comparisons ok"); + +const nan: number | null = Number("x"); +assert.strictEqual(nan, NaN); + +function report(run: () => void): void { + try { + run(); + console.log("no throw"); + } catch (e) { + const error = e as assert.AssertionError; + console.log(error.name, error.code, error.operator, error.generatedMessage); + console.log(JSON.stringify(error.message)); + console.log(String(error.actual), String(error.expected)); + } +} +report(() => assert.strictEqual(find([1], 1), null)); +report(() => assert.strictEqual(find([1], 2), 0)); +report(() => assert.strictEqual(label(1), null)); +report(() => assert.strictEqual(label(-1), "positive", "label mismatch")); +report(() => assert.notStrictEqual(find([1], 2), null)); +report(() => assert.notStrictEqual(label(3), "positive")); +report(() => assert.strictEqual(flag(0), false)); +report(() => assert.strictEqual(find([0], 0), -0)); + +const result = spawnSync("true"); +assert.strictEqual(result.status, 0); +assert.strictEqual(result.signal, null); +const child = spawn("true", [], { stdio: ["ignore", "pipe", "pipe"] }); +assert.notStrictEqual(child.stdout, null); +assert.notStrictEqual(child.stdout, undefined); +assert.notStrictEqual(child.stdin, undefined); +report(() => assert.notStrictEqual(child.stdin, null)); +assert.strictEqual(child.signalCode, null); +child.on("close", (code) => { + assert.strictEqual(child.exitCode, 0); + assert.strictEqual(child.signalCode, null); + console.log("child closed", code); +}); diff --git a/tests/corpus/cjs-top-level-return-later-bindings.cjs b/tests/corpus/cjs-top-level-return-later-bindings.cjs new file mode 100644 index 000000000..3cde1f32b --- /dev/null +++ b/tests/corpus/cjs-top-level-return-later-bindings.cjs @@ -0,0 +1,28 @@ +'use strict'; +// A top-level return in a CommonJS module can precede declarations that +// only the rest of the module reads. The timer and exit handler registered +// before the return run, a callback's own binding shadows a later one of +// the same name, and a function declared after the return is hoisted. +const fs = require('fs'); + +console.log('start', describe()); +setTimeout(() => { + const socket = 'inner socket'; + console.log('timer', socket); +}, 1); +process.on('exit', (code) => console.log('exit', code)); +for (const name of ['a', 'b']) { + if (name === 'b' && !fs.existsSync('/scriptc-missing-path')) return; + console.log('visited', name); +} + +const socket = 'outer socket'; +let count = 2; +var total = count + 1; +class Server {} +const [first, second] = [count, total]; +for (var i = 0; i < 2; i++) console.log('not reached', i); +console.log('not reached', socket, first, second, new Server()); +function describe() { + return 'hoisted'; +} diff --git a/tests/corpus/evolving-let-child-process.cjs b/tests/corpus/evolving-let-child-process.cjs new file mode 100644 index 000000000..2cdc34700 --- /dev/null +++ b/tests/corpus/evolving-let-child-process.cjs @@ -0,0 +1,46 @@ +// An initializer-less `let` assigned a child process on every branch keeps +// the child's type, including when callbacks read it, as Node's +// child_process tests do when they choose a command per platform. +const { spawn } = require('child_process'); + +let child; +if (process.platform === 'win32') child = spawn('cmd.exe', ['/c', 'echo', 'hi']); +else child = spawn('echo', ['hi']); +let text = ''; +child.stdout.on('data', (chunk) => { + text += chunk; +}); + +let producer, consumer; +if (process.platform === 'win32') { + producer = spawn('cmd.exe', ['/c', 'echo', 'hello&&', 'echo', 'node']); + consumer = spawn('findstr', ['o']); +} else { + producer = spawn('printf', ['hello\nnode\nand\nworld\n']); + consumer = spawn('grep', ['o']); +} +producer.stdout.on('data', (data) => { + if (!consumer.stdin.write(data)) producer.stdout.pause(); +}); +consumer.stdin.on('drain', () => producer.stdout.resume()); +producer.stdout.on('end', () => consumer.stdin.end()); +let piped = ''; +consumer.stdout.on('data', (data) => { + piped += data.toString('utf8', 0, data.length); +}); + +let quiet; +if (process.platform === 'win32') quiet = spawn('cmd.exe', ['/c', 'echo', 'quiet']); +else quiet = spawn('echo', ['quiet']); +quiet.stdout.setEncoding('utf8'); +quiet.stdout.resume(); + +let count; +if (process.argv.length > 10) count = Math.max(1, 2); +else count = Math.min(3, 4); +console.log(count.toFixed(1)); + +process.on('exit', () => { + console.log(JSON.stringify(text), JSON.stringify(piped)); + console.log(child.exitCode, producer.exitCode, consumer.exitCode, consumer.signalCode, quiet.exitCode); +}); diff --git a/tests/diagnostics/assert-fences.ts b/tests/diagnostics/assert-fences.ts index 7cf96bc5b..0a8cd9c0e 100644 --- a/tests/diagnostics/assert-fences.ts +++ b/tests/diagnostics/assert-fences.ts @@ -1,7 +1,7 @@ // The assert surface's honest fences: loose deep equality over recursive // types (equal/notEqual, deepEqual/notDeepEqual and assert/strict's aliases lower), -// reference-equality strictEqual on objects, -// union operands (narrow first), mismatched or uncomparable deep types +// reference-equality strictEqual on objects, union operands with object +// arms (narrow first), mismatched or uncomparable deep types // (typed arrays, class instances), namespace-object calls, unsupported // throws/rejects expectations (properties outside the static error // surface, empty shapes, non-literal objects, doesNotReject shapes — @@ -19,9 +19,9 @@ assert.notDeepEqual([1], [2]); const box = { n: 1 }; assert.strictEqual(box, box); // reference equality — deepStrictEqual hint -let maybe: string | undefined = "x"; +let maybe: { n: number } | undefined = box; if ("x".length === 0) maybe = undefined; -assert.strictEqual(maybe, "x"); // union operand — narrow first +assert.strictEqual(maybe, box); // union operand with an object arm — narrow first const unk: unknown = JSON.parse("{}"); assert.strictEqual(unk, { n: 1 }); // strict + composite static side: reference identity does not survive the unknown boundary diff --git a/tests/harness/__snapshots__/assert-fences.ts.txt b/tests/harness/__snapshots__/assert-fences.ts.txt index 041c20e9b..8ee90b796 100644 --- a/tests/harness/__snapshots__/assert-fences.ts.txt +++ b/tests/harness/__snapshots__/assert-fences.ts.txt @@ -7,10 +7,10 @@ assert-fences.ts:20:1 - error SC2020: 'assert.strictEqual of '{ n: number }' and hint: strictEqual on objects is reference equality — deepStrictEqual compares structure -assert-fences.ts:24:1 - error SC2020: 'assert.strictEqual of 'string | undefined' and 'string' values' is part of the standard library types but has no scriptc lowering yet +assert-fences.ts:24:1 - error SC2020: 'assert.strictEqual of '{ n: number } | undefined' and '{ n: number }' values' is part of the standard library types but has no scriptc lowering yet 23 | if ("x".length === 0) maybe = undefined; - 24 | assert.strictEqual(maybe, "x"); // union operand — narrow first + 24 | assert.strictEqual(maybe, box); // union operand with an object arm — narrow first | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | diff --git a/tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json b/tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json new file mode 100644 index 000000000..f316dc50b --- /dev/null +++ b/tests/harness/corpus-costs/assert-strict-equal-nullable.ts.json @@ -0,0 +1 @@ +{ "plain": 5208, "san": 7954 } diff --git a/tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json b/tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json new file mode 100644 index 000000000..5f011f8c6 --- /dev/null +++ b/tests/harness/corpus-costs/cjs-top-level-return-later-bindings.cjs.json @@ -0,0 +1 @@ +{ "plain": 3714, "san": 4365 } diff --git a/tests/harness/corpus-costs/evolving-let-child-process.cjs.json b/tests/harness/corpus-costs/evolving-let-child-process.cjs.json new file mode 100644 index 000000000..5196b4f3b --- /dev/null +++ b/tests/harness/corpus-costs/evolving-let-child-process.cjs.json @@ -0,0 +1 @@ +{ "plain": 4340, "san": 4815 } From 3ddebf4c31e05f2457680c4f665a61d93e8f47c0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 00:07:34 -0500 Subject: [PATCH 2/2] Read a captured handle let as the checked-dynamic value unless the checker types the read as the handle Inside a nested function, a read of a captured handle `let` narrowed to the handle whenever it was a member receiver. A function declared before the first write reads the binding as `any`, so the narrowed handle reached member reads typed `any`, and an optional read (`c?.remoteAddress`) compiled to an unconditional SC1090 throw. js-dyn-server, whose listener reads `server.listening` inside the callback passed to createServer, hit the same path. The read now narrows only when the checker types it as the handle. An optional member read keeps the tagged value, so undefined short-circuits and the handle arm narrows; other reads take the checked-dynamic value. The lowerer's closureDynLets collection is a checked journal table. Corpus evolving-let-socket-optional-reads.cjs reads and calls members through `?.` on a captured socket before and after its first write. --- .../lowering/expressions/identifiers.ts | 10 +++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 1 + ...volving-let-socket-optional-reads.cjs.json | 6 ++++++ .../evolving-let-socket-optional-reads.cjs | 20 +++++++++++++++++++ ...volving-let-socket-optional-reads.cjs.json | 1 + 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json create mode 100644 tests/corpus/evolving-let-socket-optional-reads.cjs create mode 100644 tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index eb3f7e7aa..89239246d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -24,6 +24,7 @@ import { funcOf, isUnitType, recordTextCodecClass, + typeEquals, } from "../../../ir/ir.js"; import { lowerNodeModuleIdentifier } from "../lower-node-module.js"; import { lowerCjsExportsObjectValue } from "../cjs-exports-object.js"; @@ -1251,7 +1252,14 @@ function closureDynRead( if (scopeOf(expr) === scopeOf(decl)) return null; const parent = expr.parent; const handle = lowerer.unions.get(type.unionId)?.arms.find((arm) => !isUnitType(arm)); - if (handle && ts.isPropertyAccessExpression(parent) && parent.expression === expr) { + // Only a use the checker types as the handle narrows: inside a function + // declared before the first write the binding reads as `any`. + const typedAsHandle = + handle !== undefined && typeEquals(lowerer.mapTypeOf(lowerer.typeOf(expr)) ?? DYN, handle); + if (typedAsHandle && ts.isPropertyAccessExpression(parent) && parent.expression === expr) { + // An optional read (`c?.address`) tests the tagged value: undefined + // short-circuits and the handle arm narrows. + if (parent.questionDotToken) return { kind: "varRef", localId: id, type, loc }; const helper = lowerer.narrowedArmHelper( type.unionId, handle, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0b73bc0e4..3fcc3e752 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4414,6 +4414,7 @@ export class Lowerer { checkedTable("preparedClassFactories", () => this.preparedClassFactories.size), checkedTable("preparingClassFactories", () => this.preparingClassFactories.size), checkedTable("varGlobalEntryInits", () => this.varGlobalEntryInits.size), + checkedTable("closureDynLets", () => this.closureDynLets.size), checkedTable("cycleInitFlags", () => this.cycleInitFlags.size), checkedTable("classInitFlags", () => this.classInitFlags.size), checkedTable("cycleInitDeclarations", () => this.cycleInitDeclarations.size), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json new file mode 100644 index 000000000..b5701c7db --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/evolving-let-socket-optional-reads.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/evolving-let-socket-optional-reads.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/evolving-let-socket-optional-reads.cjs b/tests/corpus/evolving-let-socket-optional-reads.cjs new file mode 100644 index 000000000..959191850 --- /dev/null +++ b/tests/corpus/evolving-let-socket-optional-reads.cjs @@ -0,0 +1,20 @@ +'use strict'; +// Optional reads and calls on a captured `let` socket, before and after +// its first write: undefined short-circuits, the socket answers. +const net = require('net'); +let c; +const server = net.createServer((s) => s.end()); +function report(label) { + console.log(label, c?.remoteAddress, c?.address()?.family, c?.remotePort > 0); +} +report('before'); +const early = () => console.log('arrow before', c?.remoteAddress ?? 'none'); +early(); +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.end(); + server.close(); + }); +}); diff --git a/tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json b/tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json new file mode 100644 index 000000000..1f406eb04 --- /dev/null +++ b/tests/harness/corpus-costs/evolving-let-socket-optional-reads.cjs.json @@ -0,0 +1 @@ +{ "plain": 5449, "san": 15501 }