From 70697b45c327c515559e65cd8e4a8602bc98941b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 02:56:39 -0500 Subject: [PATCH 1/2] Give generic arrow bindings Node's behavior before their declarations run Generic arrow and function-expression bindings compile to shared instances, and their declarations emit no store. Code that can run before the declaration then diverged from Node. - Inside a function, a nested function lowered before the declaration reads the binding through a TDZ box that nothing initialized. That covers closures above the declaration and hoisted function declarations. Such a read threw a ReferenceError even after the declaration ran. The declaration now stores the instance its declared signature pins into that box. - At module scope, calls and pinned value reads went straight to the instances even when a hoisted function or an earlier closure ran them before the declaration. A binding with such a use now has a flag that its declaration sets. - Uses that can run first check the flag. - A let or const throws Node's ReferenceError before the arguments run. - A var runs the arguments and then throws "is not a function". - Uses that follow the declaration stay direct calls: later statements, closures created after it, its own initializer, and function declarations that only such code calls. A var binding inside a function that is called from a place that can run before its declaration would need a per-call flag shared with its closures. That shape now fails with SC1090 instead of calling the instance. --- .../frontend/lowering/generic-functions.ts | 330 +++++++++++++++++- .../src/frontend/lowering/lower-stmts.ts | 17 +- .../compiler/src/frontend/lowering/lowerer.ts | 3 + ...0-generic-local-binding-early-uses.ts.json | 6 + .../7181-generic-module-binding-tdz.ts.json | 6 + .../7182-generic-binding-direct-calls.ts.json | 6 + .../7180-generic-local-binding-early-uses.ts | 53 +++ .../corpus/7181-generic-module-binding-tdz.ts | 49 +++ .../7182-generic-binding-direct-calls.ts | 42 +++ tests/diagnostics/generic-value-bindings.ts | 12 + .../generic-value-bindings.ts.txt | 9 +- ...0-generic-local-binding-early-uses.ts.json | 1 + .../7181-generic-module-binding-tdz.ts.json | 1 + .../7182-generic-binding-direct-calls.ts.json | 1 + 14 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7180-generic-local-binding-early-uses.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7181-generic-module-binding-tdz.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7182-generic-binding-direct-calls.ts.json create mode 100644 tests/corpus/7180-generic-local-binding-early-uses.ts create mode 100644 tests/corpus/7181-generic-module-binding-tdz.ts create mode 100644 tests/corpus/7182-generic-binding-direct-calls.ts create mode 100644 tests/harness/corpus-costs/7180-generic-local-binding-early-uses.ts.json create mode 100644 tests/harness/corpus-costs/7181-generic-module-binding-tdz.ts.json create mode 100644 tests/harness/corpus-costs/7182-generic-binding-direct-calls.ts.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index abcd5a584..d0e07582c 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -3,15 +3,20 @@ import * as ts from "../ts7/adapter.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import type { Lowerer } from "./lowerer.js"; import { + BOOL, DYN, type IrExpr, type IrFunction, type IrParam, type IrStmt, type IrType, + type SrcLoc, + STRING, + VOID, isUnitType, typeEquals, } from "../../ir/ir.js"; +import { nodeThrowExpr } from "../../ir/build.js"; import { isJsSourceFile, locOf } from "../program.js"; import { typeKey } from "../type-mapper.js"; import { functionCanReturnUndefined } from "../function-completion.js"; @@ -22,7 +27,7 @@ import { nsPathPrefix } from "./lower-namespaces.js"; import { declSymbolOf } from "./lower-modules.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; import { rejectStaticThis } from "./static-this.js"; -import { jsBindingHasOpenWrites } from "./lower-stmts.js"; +import { isVarDeclared, jsBindingHasOpenWrites } from "./lower-stmts.js"; import { type ParamShape, paramShapes, @@ -91,6 +96,15 @@ export interface GenericFnInfo { * declaration makes a new function object in JavaScript, while the * compiled instances are shared module functions, so only calls lower. */ functionScoped?: true; + /** The declaration of a generic arrow/function-expression binding. */ + bindingDecl?: ts.VariableDeclaration; + /** A module-scope binding that code can use before its declaration + * runs: the BOOL global its declaration sets, and whether it is a + * `let`/`const` (a temporal dead zone) or a `var` (undefined). */ + presence?: { flagId: string; lexical: boolean }; + /** runsAfterDeclaration's answers for the function declarations whose + * calls it placed. */ + ordering?: Map; /** IMPLICIT-ANY monomorphization (npm-static JS): parallel to * decl.parameters — the param's own symbol when the slot is a BINDABLE * implicit-any parameter (untyped, identifier-named, never written in @@ -257,7 +271,14 @@ export function lowerGenericCall( ? implicitCallInstance(lowerer, expr, info) : genericCallInstance(lowerer, expr, info); const args = lowerer.completeArgs(expr.arguments.nodes, instance.params, loc, expr); - return { kind: "call", callee: instance.name, args, type: instance.returnType, loc }; + const call: IrExpr & { kind: "call" } = { + kind: "call", + callee: instance.name, + args, + type: instance.returnType, + loc, + }; + return guardEarlyBindingCall(lowerer, info, expr, call); } /** The instance a CALL of a generic function-like names: resolved @@ -1233,6 +1254,47 @@ export function lowerGenericFnValue( } } if (!target) fenceUnpinned(); + return guardEarlyBindingValue( + lowerer, + info, + ref, + pinnedGenericClosure(lowerer, ref, info, target, fenceUnpinned), + ); +} + +/** 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 + * a read resolves to an ordinary TDZ local, so the declaration assigns it + * the instance its concrete declared signature pins. */ +export function genericBindingStoredValue( + lowerer: Lowerer, + decl: ts.VariableDeclaration, + info: GenericFnInfo, +): IrExpr { + const name = decl.name as ts.Identifier; + const fenceUnpinned = (): never => + lowerer.unsupported( + "SC1090", + name, + `generic function bindings read before their declaration without a concrete declared signature (annotate '${info.baseName}' with one, or declare it before its uses)`, + ); + const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(name)); + if (sigs.length !== 1) fenceUnpinned(); + return pinnedGenericClosure(lowerer, name, info, sigs[0]!, fenceUnpinned); +} + +/** The closure of the instance of `info` that `target` pins, for a value + * read at `ref`. `fenceUnpinned` reports a signature that leaves a type + * parameter unbound. */ +function pinnedGenericClosure( + lowerer: Lowerer, + ref: ts.Expression, + info: GenericFnInfo, + target: ts.Signature, + fenceUnpinned: () => never, +): IrExpr { + const loc = locOf(ref); const bindings = new Map(); unifySignatureBindings(lowerer, info, target, bindings); const hofBinding = runtimeOptionalHofGenericBinding(lowerer, ref, info); @@ -2565,9 +2627,18 @@ export function bindingGenericFnInfoOf( typeParams, instances: new Map(), ...(functionScoped ? { functionScoped: true as const } : {}), + bindingDecl: decl, }; lowerer.bindingGenericFns.set(fnNode, info); lowerer.genericFnsBySymbol.set(sym, info); + // Module-scope uses resolve to the instances from the start, so those + // that can run first need the flag. Inside a function the declaration + // registers before any later use lowers, and earlier uses read a TDZ + // box (genericBindingStoredValue). + if (!functionScoped) { + const presence = bindingPresenceOf(lowerer, info, decl, sym); + if (presence !== undefined) info.presence = presence; + } if (ts.isFunctionExpression(fnNode) && fnNode.name !== undefined) { const inner = lowerer.checker.getSymbolAtLocation(fnNode.name); if (inner) lowerer.genericFnsBySymbol.set(inner, info); @@ -2575,6 +2646,261 @@ export function bindingGenericFnInfoOf( return info; } +/** Whether `use`, a reference to the binding `info` declares, can only + * run after the declaration has run: it follows the declaration in its + * statement list (closures count where they are created), sits inside the + * initializer itself, or is inside a function declaration whose every + * reference does. References from other modules run after this module's + * body, and export specifiers only export. A use the check cannot place + * answers false. */ +export function runsAfterDeclaration(lowerer: Lowerer, info: GenericFnInfo, use: ts.Node): boolean { + const decl = info.bindingDecl; + if (decl === undefined) return true; + info.ordering ??= new Map(); + return useRunsAfter(lowerer, decl, use, info.ordering, new Set()); +} + +function useRunsAfter( + lowerer: Lowerer, + decl: ts.VariableDeclaration, + use: ts.Node, + memo: Map, + inProgress: Set, +): boolean { + if (ts.getSourceFileOfNode(use) !== ts.getSourceFileOfNode(decl)) return true; + const init = decl.initializer; + if (init !== undefined && use.pos >= init.pos && use.end <= init.end) return true; + const stmt = decl.parent?.parent; + const list = stmt?.parent; + if (stmt === undefined || list === undefined) return false; + for (let n: ts.Node = use; n.parent !== undefined; n = n.parent) { + if (n.parent === list) return n === stmt ? use.pos >= decl.end : n.pos >= stmt.end; + if (ts.isFunctionDeclaration(n)) return functionRunsAfter(lowerer, decl, n, memo, inProgress); + } + return false; +} + +/** Whether every call of the function declaration `fn` runs after `decl` + * has run. A function in progress counts as running after: its calls of + * itself, directly or through other functions, add no earlier caller. + * Only answers that depend on no function in progress are memoized. */ +function functionRunsAfter( + lowerer: Lowerer, + decl: ts.VariableDeclaration, + fn: ts.FunctionDeclaration, + memo: Map, + inProgress: Set, +): boolean { + const known = memo.get(fn); + if (known !== undefined) return known; + if (inProgress.has(fn)) return true; + // `export default function () {}`: only importers call it, after this + // module's body has run. + if (fn.name === undefined) return true; + inProgress.add(fn); + let result = true; + try { + const symbol = lowerer.checker.getSymbolAtLocation(fn.name); + if (symbol === undefined) result = false; + else { + for (const ref of bindingReferences(lowerer, ts.getSourceFileOfNode(fn), symbol, fn.name)) { + if (ref.pos >= fn.pos && ref.end <= fn.end) continue; + if (!useRunsAfter(lowerer, decl, ref, memo, inProgress)) { + result = false; + break; + } + } + } + } finally { + inProgress.delete(fn); + } + if (inProgress.size === 0) memo.set(fn, result); + return result; +} + +/** The value references of `symbol` in `file` other than `name` (its + * declaration name): identifiers spelled like it that resolve to it, + * outside type positions and export specifiers. */ +function bindingReferences( + lowerer: Lowerer, + file: ts.SourceFile, + symbol: ts.Symbol, + name: ts.Identifier, +): ts.Identifier[] { + let byName = lowerer.bindingIdentifiers.get(file); + if (byName === undefined) { + byName = new Map(); + lowerer.bindingIdentifiers.set(file, byName); + } + let idents = byName.get(name.text); + if (idents === undefined) { + const found: ts.Identifier[] = []; + ts.walkPreorder(file, (node) => { + if (ts.isIdentifier(node) && node.text === name.text) found.push(node); + return undefined; + }); + lowerer.checker.prefetchSymbolNodesExact(found); + byName.set(name.text, found); + idents = found; + } + return idents.filter((ident) => { + if (ident === name) return false; + const parent = ident.parent; + if (parent === undefined || ts.isExportSpecifier(parent) || ts.isExportAssignment(parent)) + return false; + let resolved = lowerer.checker.getSymbolAtLocation(ident); + if (ts.isShorthandPropertyAssignment(parent) && parent.name === ident) + resolved = lowerer.checker.getShorthandAssignmentValueSymbol(parent) ?? resolved; + if (resolved !== symbol) return false; + for (let p: ts.Node | undefined = parent; p !== undefined; p = p.parent) { + if (ts.isTypeNode(p) && p.kind !== ts.SyntaxKind.ExpressionWithTypeArguments) return false; + if (ts.isStatement(p) || ts.isFunctionLike(p)) break; + } + return true; + }); +} + +/** The presence flag of a module-scope generic binding that code can use + * before its declaration runs (a hoisted function or an earlier closure + * called during module evaluation), or undefined when every use runs + * after it. The declaration statement sets the flag. */ +function bindingPresenceOf( + lowerer: Lowerer, + info: GenericFnInfo, + decl: ts.VariableDeclaration, + symbol: ts.Symbol, +): GenericFnInfo["presence"] { + const name = decl.name as ts.Identifier; + const early = bindingReferences(lowerer, ts.getSourceFileOfNode(decl), symbol, name).some( + (ref) => !runsAfterDeclaration(lowerer, info, ref), + ); + if (!early) return undefined; + const flagId = `%g.${info.qualifiedName}%initialized`; + lowerer.globalsList.push({ id: flagId, name: "%initialized", type: BOOL, mutable: true }); + return { flagId, lexical: !isVarDeclared(decl) }; +} + +/** The statement that records a generic binding's declaration as run, + * for a binding with a presence flag. */ +export function genericBindingPresenceStore( + info: GenericFnInfo, + decl: ts.VariableDeclaration, +): IrStmt | null { + if (info.presence === undefined) return null; + const loc = locOf(decl); + return { + kind: "assign", + localId: info.presence.flagId, + value: { kind: "boolLit", value: true, type: BOOL, loc }, + loc, + }; +} + +/** A call of a generic binding where the binding may not be initialized + * yet. A `let`/`const` binding in its temporal dead zone throws Node's + * ReferenceError before the arguments run; an unassigned `var` is + * undefined, so the arguments run and the call throws "is not a + * function". Calls that run after the declaration stay direct. */ +function guardEarlyBindingCall( + lowerer: Lowerer, + info: GenericFnInfo, + expr: ts.CallExpression, + call: IrExpr & { kind: "call" }, +): IrExpr { + if (info.bindingDecl === undefined || runsAfterDeclaration(lowerer, info, expr)) return call; + const loc = call.loc; + const presence = requirePresence(lowerer, info, expr.expression); + const flag: IrExpr = { kind: "varRef", localId: presence.flagId, type: BOOL, loc }; + if (presence.lexical) + return { + kind: "seqExpr", + stmts: [tdzCheck(flag, info.baseName, loc)], + result: call, + type: call.type, + loc, + }; + // Arguments first, in order, then the check: Node evaluates them + // before it finds the callee is not callable. + const stmts: IrStmt[] = []; + const args = call.args.map((arg): IrExpr => { + const id = `%earlyCallArg.${lowerer.ctx.locals.length}`; + lowerer.ctx.locals.push({ id, name: "%earlyCallArg", type: arg.type, mutable: false }); + stmts.push({ kind: "varDecl", localId: id, init: arg, loc }); + return { kind: "varRef", localId: id, type: arg.type, loc }; + }); + return { + kind: "seqExpr", + stmts, + result: { + kind: "ternary", + cond: flag, + then: { ...call, args }, + else_: nodeThrowExpr(1, "", `${info.baseName} is not a function`, call.type, loc), + type: call.type, + loc, + }, + type: call.type, + loc, + }; +} + +/** A generic binding read as a value where it may not be initialized + * yet: a `let`/`const` binding checks its temporal dead zone first. */ +function guardEarlyBindingValue( + lowerer: Lowerer, + info: GenericFnInfo, + ref: ts.Expression, + value: IrExpr, +): IrExpr { + if (info.bindingDecl === undefined || runsAfterDeclaration(lowerer, info, ref)) return value; + const presence = requirePresence(lowerer, info, ref); + if (!presence.lexical) + lowerer.unsupported( + "SC1090", + ref, + `generic 'var' bindings read as values before their declaration runs (Node reads undefined there; declare '${info.baseName}' with const)`, + ); + const loc = value.loc; + const flag: IrExpr = { kind: "varRef", localId: presence.flagId, type: BOOL, loc }; + return { + kind: "seqExpr", + stmts: [tdzCheck(flag, info.baseName, loc)], + result: value, + type: value.type, + loc, + }; +} + +/** The presence flag a use that can run before the declaration checks. + * Only module-scope bindings have one: a binding inside a function would + * need a per-call flag that its closures share. */ +function requirePresence( + lowerer: Lowerer, + info: GenericFnInfo, + blame: ts.Node, +): NonNullable { + if (info.presence !== undefined) return info.presence; + return lowerer.unsupported( + "SC1090", + blame, + `generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding '${info.baseName}' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses)`, + ); +} + +function tdzCheck(flag: IrExpr, name: string, loc: SrcLoc): IrStmt { + return { + kind: "exprStmt", + expr: { + kind: "intrinsic", + name: "module.tdzCheck", + args: [flag, { kind: "strLit", value: name, type: STRING, loc }], + type: VOID, + loc, + }, + loc, + }; +} + /** `const h = id` — a binding ALIASING a generic function (a top-level * declaration, a registered generic binding, or another alias — resolved * left to right in declaration order). The alias registers the SAME info diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index e3480cf9c..7d9425a28 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -138,6 +138,8 @@ import { bindingGenericFnAliasInfoOf, bindingGenericFnInfoOf, bindingGenericFnNodeOf, + genericBindingPresenceStore, + genericBindingStoredValue, implicitLocalFnInfoOf, implicitLocalFnNodeOf, implicitMethodCallInfersReturn, @@ -5108,8 +5110,19 @@ export function lowerVarDecl( { const gfnNode = bindingGenericFnNodeOf(decl) ?? bindingContextualGenericFnNodeOf(lowerer, decl); if (gfnNode) { - bindingGenericFnInfoOf(lowerer, decl, gfnNode); - return null; + const info = bindingGenericFnInfoOf(lowerer, decl, gfnNode); + // A nested function that lowered before this declaration read the + // binding as an ordinary TDZ box (predeclareForwardCapture); the + // declaration initializes it, so those reads see the function once + // it runs and a ReferenceError before. + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined; + if (pre) { + lowerer.ctx.tdzPredeclared.delete(symbol!); + const value = genericBindingStoredValue(lowerer, decl, info); + return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(decl) }; + } + return genericBindingPresenceStore(info, decl); } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ee8ba7ecb..751aca523 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1906,6 +1906,9 @@ export class Lowerer { * pinned values resolve through genericFnOf exactly like top-level * generic function declarations (bindingGenericFnInfoOf). */ readonly bindingGenericFns = new Map(); + /** Identifiers by spelling, per file, for the generic bindings' use + * ordering (runsAfterDeclaration). */ + readonly bindingIdentifiers = new Map>(); /** Per-symbol result of the never-reassigned file scan * (bindingNeverReassigned — object-literal generic-method receivers). */ readonly neverReassignedCache = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7180-generic-local-binding-early-uses.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7180-generic-local-binding-early-uses.ts.json new file mode 100644 index 000000000..5fb971826 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7180-generic-local-binding-early-uses.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7180-generic-local-binding-early-uses.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7181-generic-module-binding-tdz.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7181-generic-module-binding-tdz.ts.json new file mode 100644 index 000000000..a67424e94 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7181-generic-module-binding-tdz.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7181-generic-module-binding-tdz.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7182-generic-binding-direct-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7182-generic-binding-direct-calls.ts.json new file mode 100644 index 000000000..b641615f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7182-generic-binding-direct-calls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7182-generic-binding-direct-calls.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7180-generic-local-binding-early-uses.ts b/tests/corpus/7180-generic-local-binding-early-uses.ts new file mode 100644 index 000000000..85458d973 --- /dev/null +++ b/tests/corpus/7180-generic-local-binding-early-uses.ts @@ -0,0 +1,53 @@ +// Generic arrow bindings declared inside functions, used by code that runs +// before or after the declaration: closures created above the declaration +// and hoisted function declarations read the binding like any const, so a +// use before the declaration throws a ReferenceError and a later one sees +// the function. +function describe(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +function make(): (x: number) => number { + const read = (): ((x: number) => number) => id; + const id: (x: number) => number = (x: T): T => x; + return read(); +} +console.log(make()(7)); + +function outer(flag: boolean): number { + if (flag) return helper(); + const id: (x: number) => number = (x: T): T => x; + return helper(); + + function helper(): number { + return id(1); + } +} +console.log(outer(false)); +try { + console.log(outer(true)); +} catch (error) { + console.log(describe(error)); +} + +function run(): void { + const early = (): number => id(2); + try { + early(); + } catch (error) { + console.log(describe(error)); + } + const id: (x: number) => number = (x: T): T => x; + console.log(early()); +} +run(); + +function counter(): () => number { + let total = 0; + const step = (): number => (total = add(total, 1)); + const add: (a: number, b: number) => number = (a: T, b: T): number => a + b; + step(); + step(); + return step; +} +console.log(counter()()); diff --git a/tests/corpus/7181-generic-module-binding-tdz.ts b/tests/corpus/7181-generic-module-binding-tdz.ts new file mode 100644 index 000000000..d2d2915fb --- /dev/null +++ b/tests/corpus/7181-generic-module-binding-tdz.ts @@ -0,0 +1,49 @@ +// A module-scope generic arrow binding is in its temporal dead zone until +// its declaration runs: a hoisted function or an earlier closure that calls +// it during module evaluation throws a ReferenceError, and calls made after +// the declaration reach the function. A `var` binding is undefined until +// then: the call's arguments run, then it throws "is not a function". +function describe(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +function early(): number { + return id(1); +} +const before = (): string => wrap("b"); + +try { + console.log(early()); +} catch (error) { + console.log(describe(error)); +} +try { + console.log(before()); +} catch (error) { + console.log(describe(error)); +} + +const id = (x: T): T => x; +let wrap = (x: T): string => `[${String(x)}]`; + +console.log(early(), before(), id("direct"), wrap(2)); + +function later(): string { + return wrap(id(3)); +} +console.log(later()); + +function callMissing(): number { + return missing(log("argument")); +} +function log(text: string): number { + console.log(text); + return 1; +} +try { + console.log(callMissing()); +} catch (error) { + console.log(describe(error)); +} +var missing = (x: T): T => x; +console.log(callMissing(), missing(5)); diff --git a/tests/corpus/7182-generic-binding-direct-calls.ts b/tests/corpus/7182-generic-binding-direct-calls.ts new file mode 100644 index 000000000..c7b5397e1 --- /dev/null +++ b/tests/corpus/7182-generic-binding-direct-calls.ts @@ -0,0 +1,42 @@ +// Generic arrow bindings whose uses all run after the declaration: later +// statements, closures created after it, later declarators, recursion and +// function declarations that only later code calls. These calls go +// straight to the compiled instances. +const pair = (x: T): [T, T] => [x, x]; +const doubled = pair(2), labels = pair("a"); +console.log(doubled.join(","), labels.join(",")); + +function useLater(): string { + return pair(true).join(","); +} +console.log(useLater()); + +export function exported(n: number): number { + return pair(n)[0] + 1; +} +console.log(exported(4)); + +function local(): string { + var twice = (x: T): number => x * 2; + const call = (): number => twice(4); + const depth = (xs: T[], n: number): number => (n <= 0 ? xs.length : depth(xs, n - 1) + 1); + return `${twice(1)} ${call()} ${depth([1, 2], 3)} ${helper()}`; + + function helper(): string { + return pair("h").join(""); + } +} +console.log(local()); + +function loop(): string[] { + const out: string[] = []; + const later: (() => string)[] = []; + for (let i = 0; i < 3; i++) { + const tag = (x: T): string => `#${String(x)}`; + later.push(() => tag("late")); + out.push(tag(i * 10)); + } + for (const f of later) out.push(f()); + return out; +} +console.log(loop().join(" ")); diff --git a/tests/diagnostics/generic-value-bindings.ts b/tests/diagnostics/generic-value-bindings.ts index a7de7aa1b..7c786c67c 100644 --- a/tests/diagnostics/generic-value-bindings.ts +++ b/tests/diagnostics/generic-value-bindings.ts @@ -60,3 +60,15 @@ function throughParam(a: Amb): void { console.log(a.m(4)); } throughParam(amb); + +// A `var` binding inside a function, declared in a branch and called from +// a closure: when the branch does not run, Node finds the binding undefined. +// The compiled instances have no per-call flag for that, so the call fences. +function branchVar(flag: boolean): number { + if (flag) { + var picked = (x: T): T => x; + } + const invoke = (): number => picked(1); + return invoke(); +} +console.log(branchVar(true)); diff --git a/tests/harness/__snapshots__/generic-value-bindings.ts.txt b/tests/harness/__snapshots__/generic-value-bindings.ts.txt index 04fa9d069..835b84fa7 100644 --- a/tests/harness/__snapshots__/generic-value-bindings.ts.txt +++ b/tests/harness/__snapshots__/generic-value-bindings.ts.txt @@ -92,4 +92,11 @@ generic-value-bindings.ts:60:15 - error SC1090: calls of the generic method 'm' 59 | function throughParam(a: Amb): void { 60 | console.log(a.m(4)); | ^~~~~~ - 61 | } \ No newline at end of file + 61 | } + +generic-value-bindings.ts:71:32 - error SC1090: generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding 'picked' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses) are not supported yet + + 70 | } + 71 | const invoke = (): number => picked(1); + | ^~~~~~ + 72 | return invoke(); \ No newline at end of file diff --git a/tests/harness/corpus-costs/7180-generic-local-binding-early-uses.ts.json b/tests/harness/corpus-costs/7180-generic-local-binding-early-uses.ts.json new file mode 100644 index 000000000..929b49d23 --- /dev/null +++ b/tests/harness/corpus-costs/7180-generic-local-binding-early-uses.ts.json @@ -0,0 +1 @@ +{ "plain": 2542, "san": 2911 } diff --git a/tests/harness/corpus-costs/7181-generic-module-binding-tdz.ts.json b/tests/harness/corpus-costs/7181-generic-module-binding-tdz.ts.json new file mode 100644 index 000000000..321663d8b --- /dev/null +++ b/tests/harness/corpus-costs/7181-generic-module-binding-tdz.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 2870 } diff --git a/tests/harness/corpus-costs/7182-generic-binding-direct-calls.ts.json b/tests/harness/corpus-costs/7182-generic-binding-direct-calls.ts.json new file mode 100644 index 000000000..e9da8d46e --- /dev/null +++ b/tests/harness/corpus-costs/7182-generic-binding-direct-calls.ts.json @@ -0,0 +1 @@ +{ "plain": 2686, "san": 3069 } From 0f6ea63f21f8a0f044177ebb6f36fc3cf96fac16 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 06:17:05 -0500 Subject: [PATCH 2/2] Give each evaluation of a forward-read generic binding its own function A generic binding inside a function that a nested function read before the declaration lowered stores its instance into a TDZ box when the declaration runs. That value was the instance's closure, which the backend interns for module functions, so every evaluation of the declaration stored the same function object. JavaScript makes a new one each time. The stored value is now a closure over a lifted forwarder that calls the instance, so each evaluation allocates a new closure; the forwarder is created once per instance. Async and rest-parameter bindings read this way report SC1090. Corpus 7180 checks the identities. --- .../frontend/lowering/generic-functions.ts | 71 ++++++++++++++++++- .../7180-generic-local-binding-early-uses.ts | 16 +++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index d0e07582c..e8d3241c0 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -16,7 +16,7 @@ import { isUnitType, typeEquals, } from "../../ir/ir.js"; -import { nodeThrowExpr } from "../../ir/build.js"; +import { helperLoc, nodeThrowExpr } from "../../ir/build.js"; import { isJsSourceFile, locOf } from "../program.js"; import { typeKey } from "../type-mapper.js"; import { functionCanReturnUndefined } from "../function-completion.js"; @@ -102,6 +102,8 @@ export interface GenericFnInfo { * runs: the BOOL global its declaration sets, and whether it is a * `let`/`const` (a temporal dead zone) or a `var` (undefined). */ presence?: { flagId: string; lexical: boolean }; + /** genericValueForwarder's lifted functions, by instance name. */ + valueForwarders?: Map; /** runsAfterDeclaration's answers for the function declarations whose * calls it placed. */ ordering?: Map; @@ -1266,7 +1268,10 @@ export function lowerGenericFnValue( * nested function read it before the declaration lowered (`const read = * () => id; const id: (x: number) => number = (x: T): T => x`): such * a read resolves to an ordinary TDZ local, so the declaration assigns it - * the instance its concrete declared signature pins. */ + * the instance its concrete declared signature pins. Each evaluation of + * the declaration makes a new function object, as in JavaScript: the + * value is a closure over a forwarder to the instance, not the instance's + * interned closure. */ export function genericBindingStoredValue( lowerer: Lowerer, decl: ts.VariableDeclaration, @@ -1281,7 +1286,67 @@ export function genericBindingStoredValue( ); const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(name)); if (sigs.length !== 1) fenceUnpinned(); - return pinnedGenericClosure(lowerer, name, info, sigs[0]!, fenceUnpinned); + const pinned = pinnedGenericClosure(lowerer, name, info, sigs[0]!, fenceUnpinned); + const async = ts.getModifiers(info.decl)?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword); + if (pinned.kind !== "closure" || pinned.type.kind !== "func" || pinned.type.rest || async) + return lowerer.unsupported( + "SC1090", + name, + `async or rest-parameter generic function bindings read before their declaration (each evaluation of '${info.baseName}' makes a new function object)`, + ); + return { + ...pinned, + fnName: genericValueForwarder(lowerer, info, pinned.fnName, pinned.type, pinned.loc), + }; +} + +/** A lifted function that calls the instance `instance` (of type `type`) + * with its own arguments. Lifted functions get a new closure object per + * evaluation; module functions, which instances are, get one interned + * closure. */ +function genericValueForwarder( + lowerer: Lowerer, + info: GenericFnInfo, + instance: string, + type: IrType & { kind: "func" }, + at: SrcLoc, +): string { + info.valueForwarders ??= new Map(); + const existing = info.valueForwarders.get(instance); + if (existing !== undefined) return existing; + const name = `${instance}%value`; + const loc = helperLoc(at); + const params: IrParam[] = type.params.map((paramType, i) => ({ + localId: `%arg${i}.0`, + name: `%arg${i}`, + type: paramType, + })); + const call: IrExpr = { + kind: "call", + callee: instance, + args: params.map((p): IrExpr => ({ kind: "varRef", localId: p.localId, type: p.type, loc })), + type: type.ret, + loc, + }; + lowerer.liftedFns.push({ + name, + params, + returnType: type.ret, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + captures: [], + ...(ts.isFunctionExpression(info.decl) ? { ownsPrototype: true } : {}), + body: + type.ret.kind === "void" + ? [ + { kind: "exprStmt", expr: call, loc }, + { kind: "return", value: null, loc }, + ] + : [{ kind: "return", value: call, loc }], + loc, + }); + lowerer.noteEdge(name); + info.valueForwarders.set(instance, name); + return name; } /** The closure of the instance of `info` that `target` pins, for a value diff --git a/tests/corpus/7180-generic-local-binding-early-uses.ts b/tests/corpus/7180-generic-local-binding-early-uses.ts index 85458d973..f3f7816b5 100644 --- a/tests/corpus/7180-generic-local-binding-early-uses.ts +++ b/tests/corpus/7180-generic-local-binding-early-uses.ts @@ -51,3 +51,19 @@ function counter(): () => number { return step; } console.log(counter()()); + +// Each evaluation of the declaration makes a new function object. +const first = make(); +console.log(first === make(), first === first, first(8)); +function looped(): string { + const seen: ((x: string) => string)[] = []; + for (let i = 0; i < 2; i++) { + const read = (): ((x: string) => string) => tag; + const tag: (x: string) => string = function (x: T): T { + return x; + }; + seen.push(read()); + } + return `${seen[0] === seen[1]} ${seen[1]!("p")}`; +} +console.log(looped());