From bb0e0a79d5bd7a1bceb8d44e61135232f1b188cc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 08:02:00 -0700 Subject: [PATCH 1/5] 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 e20604f920..440ed7b059 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 046b34008b..98d85be698 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 b928055eb1..9aeab9358d 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 0000000000..88af117ecd --- /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 0000000000..39a2507dfd --- /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 0000000000..8d41315899 --- /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 0000000000..2769067c05 --- /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 0000000000..c7a4de8e61 --- /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 0000000000..ac480ebf9b --- /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 0000000000..35ac2e0bfd --- /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 0000000000..256338ebef --- /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 2/5] 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 0000000000..ba16cbf0ea --- /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 0000000000..699975e880 --- /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 0000000000..349c48d0c8 --- /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 0000000000..bd3bdce4ca --- /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 3/5] 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 98d85be698..404bca4a5b 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 4/5] 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 9aeab9358d..2065a9d78c 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 33018e9d8b8b5b022d2358060ff7b25f541c71f7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:47:27 -0500 Subject: [PATCH 5/5] 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 404bca4a5b..da85cee6cf 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 0000000000..1f3da1b971 --- /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 0000000000..419837ad57 --- /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 0000000000..bd74dc19c7 --- /dev/null +++ b/tests/harness/corpus-costs/generic-identity-unknown-scalars.ts.json @@ -0,0 +1 @@ +{ "plain": 8425, "san": 6439 }