From bd1bf39a765d7b8993f25862b40225388fc5e0f8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 11:18:18 -0700 Subject: [PATCH] Close recursive record knots through erased generic method members A record or union that refers back to itself only closes its recursive knot when the frame did not resolve anything through context-sensitive hooks; otherwise the same checker type could answer differently per instantiation, so the knot is refused. Erasing a generic method member (`send(message: Message): string`) resolves the method's own type parameters to `unknown`, and that resolution counted as context-sensitive. Any recursive interface declaring a generic method was therefore refused: `interface Sub extends Base { send(m: Message): string }` reached from `Base { child?: Sub }`, or a list node with a generic method beside its `next` field. The member erasure now records its own type parameters, and resolving one of them no longer counts. Consulting an enclosing binding still does, so a generic method that mentions an outer type parameter keeps the fence. The erased member's mapping is not memoized under the member's type, since the same type maps to nothing outside the erasure. pi's ReplacedSessionContext (sendMessage) extends ExtensionCommandContext, whose methods take callbacks over ReplacedSessionContext. The fence refused ExtensionAPI.registerCommand and, through it, pi's main(). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 22 ++++++- .../recursive-generic-method-members.ts.json | 6 ++ .../recursive-generic-method-members.ts | 58 +++++++++++++++++++ .../recursive-generic-method-members.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json create mode 100644 tests/corpus/recursive-generic-method-members.ts create mode 100644 tests/harness/corpus-costs/recursive-generic-method-members.ts.json diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 17f9d0d11..99766d0ab 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1255,6 +1255,10 @@ export interface TypeMapperCtx { * (erasedGenericMemberType): the signature's own type parameters * resolve to `unknown` instead of refusing the mapping. */ erasingSignature?: ts.Signature; + /** Set with erasingSignature: the signature's own type parameters, and + * whether the erasure consulted an enclosing binding. An erasure that + * resolved only its own parameters answers the same in every context. */ + erasedMember?: { own: ReadonlySet; outerConsulted: boolean }; /** True while an erased generic body lowers (its type parameters are * bound to `unknown`). */ isErasing?: () => boolean; @@ -1835,6 +1839,7 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { memoKey !== null && topLevel && contextResolutions === sensitivityAtEntry && + ctx.erasingSignature === undefined && (ctx.canMemoizeType?.() ?? true) ) { ctx.typeMemo?.set(memoKey, mapped); @@ -1991,7 +1996,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { !ERASURE_KEPT_CONTAINERS.has(ts.symbolNameOf(type.getSymbol()) ?? "") && mentionsErasedTypeParam(type, ctx, 0) ) { - contextResolutions++; + if (!ctx.erasedMember || ctx.erasedMember.outerConsulted) contextResolutions++; return DYN; } // Readonly over a program class (Object.freeze's result type) is the @@ -2001,7 +2006,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) { const bound = resolveTypeParam(type); if (bound) { - contextResolutions++; + if (!ctx.erasedMember?.own.has(type)) contextResolutions++; return bound; } } @@ -7309,10 +7314,21 @@ export function erasedGenericMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType const sig = sigs[0]!; const own = new Set(sig.getTypeParameters()); const outer = ctx.resolveTypeParam; + // Resolving only the member's own parameters does not make the erasure + // context-sensitive, so a recursive record holding a generic method + // (`interface Sub extends Base { send(m: Msg): void }` reached + // from Base) still closes its knot. Consulting an enclosing binding does. + const erasedMember = { own, outerConsulted: false }; const erased = mapType(t, { ...ctx, erasingSignature: sig, - resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)), + erasedMember, + resolveTypeParam: (param) => { + if (own.has(param)) return DYN; + if (outer === undefined) return null; + erasedMember.outerConsulted = true; + return outer(param); + }, }); return erased?.kind === "func" ? erased : undefined; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json new file mode 100644 index 000000000..6c3590ab8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/recursive-generic-method-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/recursive-generic-method-members.ts" + ], + "diags": [] +} diff --git a/tests/corpus/recursive-generic-method-members.ts b/tests/corpus/recursive-generic-method-members.ts new file mode 100644 index 000000000..1f4d8372d --- /dev/null +++ b/tests/corpus/recursive-generic-method-members.ts @@ -0,0 +1,58 @@ +// A recursive record that declares a generic method compiles. Erasing the +// method's own type parameters does not depend on any enclosing generic +// binding, so the record still closes its recursive knot: a parent +// interface whose child is a subinterface declaring a generic method, and a +// list node whose generic method sits beside a self-referencing field. + +interface Message { + content: string; + details?: T; +} + +interface TreeNode { + name: string; + child?: SendingNode; +} + +interface SendingNode extends TreeNode { + send(message: Message): string; +} + +interface ListNode { + value: number; + next: ListNode | null; + map(f: (value: number) => U): string; +} + +function describe(node: TreeNode): string { + const inner = node.child; + return inner ? `${node.name} > ${describe(inner)}` : node.name; +} + +function leaf(name: string): SendingNode { + return { name, send: () => `${name} got a message` }; +} + +const tree: SendingNode = { + name: "root", + child: { name: "middle", child: leaf("leaf"), send: () => "middle got a message" }, + send: () => "root got a message", +}; +console.log(describe(tree)); +console.log(tree.send({ content: "hello" })); +const middle = tree.child; +if (middle !== undefined) console.log(middle.send({ content: "hi", details: 42 })); + +function cons(value: number, next: ListNode | null): ListNode { + return { value, next, map: () => `node ${value}` }; +} + +let list: ListNode | null = cons(1, cons(2, cons(3, null))); +const values: number[] = []; +let labels = ""; +while (list !== null) { + values.push(list.value); + labels += `[${list.map((v) => v * 2)}]`; + list = list.next; +} +console.log(values.join(","), labels); diff --git a/tests/harness/corpus-costs/recursive-generic-method-members.ts.json b/tests/harness/corpus-costs/recursive-generic-method-members.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/recursive-generic-method-members.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 }