fix(typescript): type a class field from its initializer so it can be a receiver

A field whose type had to be inferred from its initializer produced no CALLS
edge at all — not a truncated chain, nothing. `this.p.inner().compute(x)` lost
`Outer.inner` too, an ordinary named-receiver call, because `typeOfMemberOnClass`
found no `typeBindings` entry for `p` and `foldReceiverChain` declines at its
first untypeable step rather than folding on a guessed owner.

The initializer was never invisible: `new Outer()` emitted its own constructor
edge exactly as the annotated twin does. What was missing was the step turning
that initializer into a TYPE BINDING, i.e. capture patterns for the two shapes
the query never covered:

  private p = new Outer();                       // public_field_definition value:
  private p; constructor() { this.p = new … }    // this.<field> = new …

Both are `@type-binding.constructor`, so `annotation` still outranks them in
`typeBindingStrength` and an annotated field keeps resolving through its
annotation. The assignment form carries a narrow `@type-binding.this-field`
marker on its `(this)` node — anchorCaptureFor takes the broadest range, so the
statement stays the anchor — which `tsBindingScopeFor` reads to hoist the
binding onto the Class scope, the only place `typeOfMemberOnClass` looks. The
marker must stay specific to that pattern: hoisting every constructor-inferred
binding would move method-local `const o = new Outer()` out of its own scope.

Kotlin and Swift needed no such pattern for the initializer form because one
grammar node (property_declaration) covers both a local and a stored property;
TypeScript splits them, and only the local half was ever covered.

Both self-diffing pins flip and gain rows: a method-assigned field, and a
deliberately mistyped `private p: Mismatch = new Outer()` that asserts the
source-strength tie-break executably. That row also pins a pre-existing
artifact — `Inner.compute` still resolves through the hoisted module-level
return-type binding — verified byte-identical on the pre-fix tree.

Fixes #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 12:50:07 +00:00
parent d7a659b0a7
commit 6869504408
4 changed files with 760 additions and 0 deletions

View file

@ -882,6 +882,40 @@ export const TYPESCRIPT_SCOPE_QUERY = `
type: (type_annotation
(type_identifier) @type-binding.type)) @type-binding.annotation
;; Type bindings — class field constructor-inferred: \`private p = new Outer()\`.
;; The annotation patterns above cover a field that DECLARES its type; a field
;; whose type must be inferred from its initializer matched nothing, so it had
;; no typeBinding, so \`this.p\` could not be typed and the receiver fold declined
;; the whole chain — losing even the first, ordinary named link (#2807).
;;
;; Anchored on \`public_field_definition\` exactly like the annotation patterns,
;; so the binding lands in the same (class body) scope with no bindingScopeFor
;; override. \`annotation\` outranks \`constructor-inferred\` in
;; typeBindingStrength, so \`private p: Outer = new Outer()\` still resolves
;; through its annotation regardless of which pattern matches first.
;;
;; Kotlin and Swift express both a local and a stored property with ONE grammar
;; node (property_declaration) and so needed no separate field pattern; the
;; TypeScript grammar splits them (variable_declarator vs
;; public_field_definition), which is why only the local form was ever covered.
(public_field_definition
name: (property_identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Qualified: \`private p = new models.Outer()\` — mirrors the local form above;
;; the member_expression's text is resolved via QualifiedNameIndex.
(public_field_definition
name: (property_identifier) @type-binding.name
value: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
;; Private-name field: \`#p = new Outer()\`.
(public_field_definition
name: (private_property_identifier) @type-binding.name
value: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — method return type: \`save(): User { … }\` / \`function f(): User { … }\`.
;; Function/method return-type is the type_annotation that is a direct
;; child of the function node (not the parameter's annotation). Anchor on
@ -1010,6 +1044,37 @@ export const TYPESCRIPT_SCOPE_QUERY = `
right: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — field assigned through \`this\`: \`this.p = new Outer()\` on a
;; field that declares no type (#2807). The rebind pattern above only matches a
;; bare identifier LHS, so an unannotated field assigned in the constructor had
;; no typeBinding at all. (An ANNOTATED field does not need this — its
;; annotation already types it, which is why \`private p: Outer;\` + the same
;; assignment always resolved.)
;;
;; \`@type-binding.this-field\` is a MARKER, not the anchor: it sits on the narrow
;; \`(this)\` node, so anchorCaptureFor's broadest-range rule keeps the whole
;; assignment_expression (@type-binding.constructor) as the anchor and the
;; source stays \`constructor-inferred\`. tsBindingScopeFor reads the marker to
;; hoist the binding onto the enclosing Class scope — without that hoist the
;; binding would land on the constructor's own Function scope, where
;; typeOfMemberOnClass never looks. The marker must stay specific to THIS
;; pattern: hoisting every constructor-inferred binding would move method-local
;; \`const o = new Outer()\` out of its own scope.
(assignment_expression
left: (member_expression
object: (this) @type-binding.this-field
property: (property_identifier) @type-binding.name)
right: (new_expression
constructor: (identifier) @type-binding.type)) @type-binding.constructor
;; Qualified form: \`this.p = new models.Outer()\`.
(assignment_expression
left: (member_expression
object: (this) @type-binding.this-field
property: (property_identifier) @type-binding.name)
right: (new_expression
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
(assignment_expression
left: (identifier) @type-binding.name
right: (call_expression

View file

@ -55,6 +55,20 @@ export function tsBindingScopeFor(
return walkToScope(innermost, tree, 'Class');
}
// `this.p = new Outer()` binds the FIELD, not a constructor-local, so the
// binding belongs on the class the way an annotated field's does — that is
// the only place `typeOfMemberOnClass` reads. Left on the innermost scope it
// would sit on the constructor's own Function scope and never be found
// (#2807). Same shape as the parameter-property branch above.
//
// Gated on the marker the `this.<field> = new …` pattern emits, never on
// `@type-binding.constructor` at large: that capture also fires for
// `const o = new Outer()` inside a method, and hoisting THOSE to the class
// would take method locals out of their own scope and mistype them.
if (decl['@type-binding.this-field'] !== undefined) {
return walkToScope(innermost, tree, 'Class');
}
// `var` declarations: hoist to nearest enclosing Function or Module.
const variable = decl['@declaration.variable'];
if (variable !== undefined && isVarDeclaration(variable.text)) {

View file

@ -0,0 +1,294 @@
/**
* The PDG inter-procedural descent hops through `BasicBlock.calleeIds`, so it
* can only cross a call boundary that the RESOLVER managed to resolve. Chained
* receiver calls (`out.inner().compute(x)`) are resolved by the receiver-typing
* pass, whose resolved ids reach `calleeIds` through a separate sink from the
* plain-call path — which means the chain could regress there without any
* plain-call test noticing.
*
* This pins the resolver -> PDG seam for a chain: the block holding the chained
* statement must carry the id of EVERY link, not just the first. The descent's
* behaviour once the ids are present is covered by impact-pdg-interproc and
* impact-pdg-fullchain-e2e; what those cannot catch is a chain link silently
* missing from the column they both read.
*
* ── WHAT REACHES THE CELL ─────────────────────────────────────────────────────
*
* Measured against this fixture (one repo per shape and all shapes in one repo
* agree, so the rows do not contaminate each other). Every receiver form now
* carries its whole chain, whether the receiver's type is declared or inferred:
*
* receiver form calleeIds cell
* --------------------------------------------------- ------------------------
* local `const o = new Outer()` Outer.inner + Inner.compute
* field `private p: Outer = new Outer()` Outer.inner + Inner.compute
* field `private p: Outer;` + ctor `this.p = new ...` Outer.inner + Inner.compute
* receiver is a call result `makeOuter().inner()...` makeOuter + both links
* three links `o.inner().mid().compute()` all three links
* field `private p = new Outer()` (INFERRED) Outer.inner + Inner.compute
* field `private p;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute
*
* The last two rows were EMPTY before #2807 — not a truncated chain, an empty
* cell, so the descent could not cross into `Outer.inner` either even though
* that call has a perfectly ordinary named receiver. The cause was upstream of
* the PDG entirely: an untyped field had no type binding, so the receiver fold
* declined at its first step and no link was ever resolved to put here. The
* resolver-level view of the same fact, with the full shape table, lives in
* `test/integration/resolvers/typescript-inferred-field-receiver.test.ts`.
*
* Self-contained fixture rather than an addition to `fixtures/pdg-repo` — that
* fixture is shared by eight suites including a snapshot test, so growing it to
* cover one seam churns unrelated expectations.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
// The PRODUCTION reader of the cell: splits on `CALLEE_ID_SEP`
// (src/core/ingestion/cfg/emit.ts) and drops the truncation sentinel. Both the
// statement-precise bridge and the inter-procedural descent go through it, so
// asserting on its output is asserting on exactly the ids the descent sees —
// and it yields whole ids, which a substring match over the raw cell would not.
import { splitCalleeIds } from '../../../src/mcp/local/pdg-impact.js';
const FIXTURE_PATH = 'src/app.ts';
// Every caller below chains `.compute()` onto the RESULT of `.inner()`; the
// second call has no named receiver, so it resolves only if the receiver's type
// is carried through the chain. Only the receiver FORM varies between rows.
const CHAINED_SOURCE = `export class Mid {
compute(v: number): number {
return v * 3;
}
}
export class Inner {
compute(v: number): number {
return v * 2;
}
mid(): Mid {
return new Mid();
}
}
export class Outer {
inner(): Inner {
return new Inner();
}
}
export function makeOuter(): Outer {
return new Outer();
}
export function runLocalConst(x: number): number {
const localConst = new Outer();
const r = localConst.inner().compute(x);
return r;
}
export function runCallResultReceiver(x: number): number {
const r = makeOuter().inner().compute(x);
return r;
}
export function runThreeLink(x: number): number {
const threeLink = new Outer();
const r = threeLink.inner().mid().compute(x);
return r;
}
export class AnnotatedFieldCaller {
private annotated: Outer = new Outer();
run(x: number): number {
const r = this.annotated.inner().compute(x);
return r;
}
}
export class InferredFieldCaller {
private inferred = new Outer();
run(x: number): number {
const r = this.inferred.inner().compute(x);
return r;
}
}
export class CtorAssignedAnnotatedCaller {
private ctorTyped: Outer;
constructor() {
this.ctorTyped = new Outer();
}
run(x: number): number {
const r = this.ctorTyped.inner().compute(x);
return r;
}
}
export class CtorAssignedInferredCaller {
private ctorUntyped;
constructor() {
this.ctorUntyped = new Outer();
}
run(x: number): number {
const r = this.ctorUntyped.inner().compute(x);
return r;
}
}
`;
// EXACT resolved ids — never substrings. `Inner.compute` as a substring is also
// satisfied by `Inner.computeExtra` and by `OtherInner.compute`, while the
// descent keys on the whole id for its span and CALL_SUMMARY lookups. The `#N`
// suffix is the arity disambiguator the resolver mints.
const OUTER_INNER = `Method:${FIXTURE_PATH}:Outer.inner#0`;
const INNER_COMPUTE = `Method:${FIXTURE_PATH}:Inner.compute#1`;
const INNER_MID = `Method:${FIXTURE_PATH}:Inner.mid#0`;
const MID_COMPUTE = `Method:${FIXTURE_PATH}:Mid.compute#1`;
const MAKE_OUTER = `Function:${FIXTURE_PATH}:makeOuter`;
/** Every link's id lands in the cell. The only value today — the
* inference-typed rows joined it in #2807 — but kept as a named type so a
* future gap row has somewhere to say so instead of being a bare boolean. */
type ChainResolution = 'reaches-pdg';
interface ReceiverShape {
/** Row name; also the assertion key in the diff when a row moves. */
readonly name: string;
/** Unique fragment of the chained statement, used to find its block. */
readonly marker: string;
/** Every link of the chain, as an exact resolved id. */
readonly links: readonly string[];
readonly resolution: ChainResolution;
}
const RECEIVER_SHAPES: readonly ReceiverShape[] = [
{
name: 'local-const',
marker: 'localConst.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'reaches-pdg',
},
{
name: 'annotated-field',
marker: 'this.annotated.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'reaches-pdg',
},
{
name: 'ctor-assigned-annotated',
marker: 'this.ctorTyped.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'reaches-pdg',
},
{
name: 'call-result-receiver',
marker: 'makeOuter().inner().compute(',
links: [MAKE_OUTER, OUTER_INNER, INNER_COMPUTE],
resolution: 'reaches-pdg',
},
{
name: 'three-link-chain',
marker: 'threeLink.inner().mid().compute(',
links: [OUTER_INNER, INNER_MID, MID_COMPUTE],
resolution: 'reaches-pdg',
},
// ── Inference-typed fields (#2807) ────────────────────────────────────────
// Identical to the two annotated rows above except that the field declares no
// type, so its type comes from the initializer. Both emitted an EMPTY cell
// until #2807 — the descent could not cross even `Outer.inner`, a plainly
// named receiver call.
{
name: 'inferred-field',
marker: 'this.inferred.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'reaches-pdg',
},
{
name: 'ctor-assigned-inferred',
marker: 'this.ctorUntyped.inner().compute(',
links: [OUTER_INNER, INNER_COMPUTE],
resolution: 'reaches-pdg',
},
];
interface BlockCell {
readonly text: string;
readonly ids: readonly string[];
}
const repos = createTempDirPool('gn-pdg-chain-');
let blocks: readonly BlockCell[] = [];
function blocksFor(marker: string): readonly BlockCell[] {
return blocks.filter((b) => b.text.includes(marker));
}
function idsFor(marker: string): readonly string[] {
const matched = blocksFor(marker);
// Exactly one block spans each chained statement; a fixture drift that split
// or dropped it would otherwise make the id assertions vacuous.
expect(matched).toHaveLength(1);
return matched[0].ids;
}
/** The behaviour a `reaches-pdg` row has today. */
function assertChainReachesPdg(shape: ReceiverShape): void {
const ids = idsFor(shape.marker);
// Non-empty first: an unresolvable receiver drops EVERY link, so this
// separates "the chained link regressed" from "the whole cell went away".
expect(ids).not.toHaveLength(0);
expect(ids).toEqual(expect.arrayContaining([...shape.links]));
}
describe('PDG calleeIds — chained receiver calls by receiver form (#2802 follow-up)', () => {
beforeAll(async () => {
const dir = repos.dir();
fs.mkdirSync(path.join(dir, path.dirname(FIXTURE_PATH)));
fs.writeFileSync(path.join(dir, FIXTURE_PATH), CHAINED_SOURCE);
const result = await runPipelineFromRepo(dir, () => {}, { pdg: true });
const collected: BlockCell[] = [];
result.graph.forEachNode((n) => {
if (n.label !== 'BasicBlock') return;
collected.push({
text: typeof n.properties.text === 'string' ? n.properties.text : '',
ids: splitCalleeIds(n.properties.calleeIds),
});
});
blocks = collected;
}, 180000);
it('every receiver shape contributes exactly one chained-call block', () => {
const counts = Object.fromEntries(
RECEIVER_SHAPES.map((s) => [s.name, blocksFor(s.marker).length]),
);
expect(counts).toEqual(Object.fromEntries(RECEIVER_SHAPES.map((s) => [s.name, 1])));
});
for (const shape of RECEIVER_SHAPES.filter((s) => s.resolution === 'reaches-pdg')) {
it(`${shape.name}: every chain link's exact id reaches calleeIds`, () => {
assertChainReachesPdg(shape);
});
}
// The inference-typed rows are asserted as a SET, in one assertion, on top of
// their per-row checks above: #2807's signature was that both of them emptied
// together, so a regression that reopened the gap for only one shape has to
// show up as a diff here rather than as a single quiet row failure.
it('both inference-typed receivers carry the whole chain, not just the first link', () => {
const inferred = ['inferred-field', 'ctor-assigned-inferred'] as const;
const observed = Object.fromEntries(
inferred.map((name) => {
const shape = RECEIVER_SHAPES.find((s) => s.name === name);
if (shape === undefined) throw new Error(`fixture drift: no row named ${name}`);
return [name, [...idsFor(shape.marker)].sort()];
}),
);
expect(observed).toEqual({
'inferred-field': [INNER_COMPUTE, OUTER_INNER].sort(),
'ctor-assigned-inferred': [INNER_COMPUTE, OUTER_INNER].sort(),
});
});
});

View file

@ -0,0 +1,387 @@
/**
* Resolver pin: every TypeScript receiver FORM resolves a chained call, whether
* the receiver's type is declared or inferred from its initializer (#2807).
*
* ── WHAT THIS FILE PINS ───────────────────────────────────────────────────────
*
* Measured against the single-file fixture below (all receiver shapes in one
* repo). Every caller runs the same statement, `<receiver>.inner().compute(x)`;
* only the receiver FORM varies:
*
* receiver form CALLS edges emitted
* ------------------------------------------------------- --------------------------
* local `const o = new Outer()` Outer.inner + Inner.compute
* field `private p: Outer = new Outer()` (ANNOTATED) Outer.inner + Inner.compute
* field `private p: Outer;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute
* field `private p: Outer;` + ctor param `this.p = p` Outer.inner + Inner.compute
* field `constructor(private p: Outer)` (param property) Outer.inner + Inner.compute
* result `makeOuter().inner().compute()` makeOuter + both links
* chain `o.inner().mid().compute()` (three links) all three links
* field `private p = new Outer()` (INFERRED) Outer.inner + Inner.compute
* field `private p;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute
* field `private p;` + method `this.p = new Outer()` Outer.inner + Inner.compute
*
* ── WHY THE LAST THREE ROWS ARE HERE (#2807) ──────────────────────────────────
*
* They used to emit NOTHING — not a partial chain, no outgoing CALLS edge at
* all, so even `Outer.inner`, a plainly named receiver call, was lost. The
* discriminator was whether the field DECLARED its type: an untyped field had
* no entry in its class scope's `typeBindings`, so `typeOfMemberOnClass` came
* back empty and `foldReceiverChain` declined at the very first step.
*
* The `new Outer()` initializer was never the problem — it always emitted its
* own constructor edge, exactly as the annotated twin does (still asserted
* below). What was missing was the step turning that initializer into a TYPE
* BINDING for the field, i.e. a `@type-binding.constructor` capture pattern
* anchored on `public_field_definition` and on `this.<field> = new …`.
*
* The annotated twins stay pinned alongside on purpose: they are what proves a
* regression would be a regression, and one of them —
* `AnnotationBeatsInitializerCaller` — deliberately mistypes its annotation so
* that the annotation-over-initializer source-strength tie-break is asserted
* executably rather than assumed.
*
* The same fact is observable one layer down as a `BasicBlock.calleeIds` cell
* in `test/integration/cfg/pdg-chained-receiver-callees.test.ts` — that is the
* PDG's view of this RESOLVER fact, behind a full `--pdg` pipeline. Keep the
* two files in step: whoever changes receiver typing changes both.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
const FIXTURE_PATH = 'src/app.ts';
const CHAINED_SOURCE = `export class Mid {
compute(v: number): number {
return v * 3;
}
}
export class Inner {
compute(v: number): number {
return v * 2;
}
mid(): Mid {
return new Mid();
}
}
export class Outer {
inner(): Inner {
return new Inner();
}
}
export function makeOuter(): Outer {
return new Outer();
}
export function runLocalConst(x: number): number {
const localConst = new Outer();
const r = localConst.inner().compute(x);
return r;
}
export function runCallResultReceiver(x: number): number {
const r = makeOuter().inner().compute(x);
return r;
}
export function runThreeLink(x: number): number {
const threeLink = new Outer();
const r = threeLink.inner().mid().compute(x);
return r;
}
export class AnnotatedFieldCaller {
private annotated: Outer = new Outer();
runAnnotatedField(x: number): number {
const r = this.annotated.inner().compute(x);
return r;
}
}
export class CtorAssignedAnnotatedCaller {
private ctorTyped: Outer;
constructor() {
this.ctorTyped = new Outer();
}
runCtorAssignedAnnotated(x: number): number {
const r = this.ctorTyped.inner().compute(x);
return r;
}
}
export class CtorParamAnnotatedCaller {
private ctorParam: Outer;
constructor(ctorParam: Outer) {
this.ctorParam = ctorParam;
}
runCtorParamAnnotated(x: number): number {
const r = this.ctorParam.inner().compute(x);
return r;
}
}
export class ParamPropertyCaller {
constructor(private paramProp: Outer) {}
runParamProperty(x: number): number {
const r = this.paramProp.inner().compute(x);
return r;
}
}
export class InferredFieldCaller {
private inferred = new Outer();
runInferredField(x: number): number {
const r = this.inferred.inner().compute(x);
return r;
}
}
export class CtorAssignedInferredCaller {
private ctorUntyped;
constructor() {
this.ctorUntyped = new Outer();
}
runCtorAssignedInferred(x: number): number {
const r = this.ctorUntyped.inner().compute(x);
return r;
}
}
export class MethodAssignedInferredCaller {
private lateBound;
setUp(): void {
this.lateBound = new Outer();
}
runMethodAssignedInferred(x: number): number {
const r = this.lateBound.inner().compute(x);
return r;
}
}
// Deliberately mistyped: the annotation says \`Mismatch\`, the initializer
// constructs an \`Outer\`. TypeScript would reject it; the resolver must still
// prefer the ANNOTATION, because \`annotation\` outranks \`constructor-inferred\`
// in \`typeBindingStrength\`. \`Mismatch\` has no \`inner\`, so a resolver that let
// the initializer win would emit \`Outer.inner\` here — the one row in this file
// that fails if the source-strength tie-break regresses.
export class Mismatch {
notInner(): number {
return 0;
}
}
export class AnnotationBeatsInitializerCaller {
private mistyped: Mismatch = new Outer();
runAnnotationBeatsInitializer(x: number): number {
const r = this.mistyped.inner().compute(x);
return r;
}
}
`;
// EXACT node ids — never names or substrings. `compute` alone is ambiguous
// between `Inner.compute` and `Mid.compute`, and matching on the source NAME
// would collide on `constructor` (two classes define one). `#N` is the arity
// disambiguator the resolver mints.
const OUTER_CLASS = `Class:${FIXTURE_PATH}:Outer`;
const OUTER_INNER = `Method:${FIXTURE_PATH}:Outer.inner#0`;
const INNER_COMPUTE = `Method:${FIXTURE_PATH}:Inner.compute#1`;
const INNER_MID = `Method:${FIXTURE_PATH}:Inner.mid#0`;
const MID_COMPUTE = `Method:${FIXTURE_PATH}:Mid.compute#1`;
const MAKE_OUTER = `Function:${FIXTURE_PATH}:makeOuter`;
/** Every chain link becomes a CALLS edge. The only value today — the
* inference-typed rows joined it in #2807 — but kept as a named type so a
* future gap row has somewhere to say so instead of being a bare boolean. */
type ChainResolution = 'resolves';
interface ReceiverShape {
/** Row name; also the assertion key in the diff when a row moves. */
readonly name: string;
/** Exact node id of the function or method holding the chained statement. */
readonly callerId: string;
/** EVERY CALLS target id this caller emits today, in any order. */
readonly targets: readonly string[];
readonly resolution: ChainResolution;
}
const RECEIVER_SHAPES: readonly ReceiverShape[] = [
{
name: 'local-const',
callerId: `Function:${FIXTURE_PATH}:runLocalConst`,
targets: [OUTER_CLASS, OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'annotated-field-initializer',
callerId: `Method:${FIXTURE_PATH}:AnnotatedFieldCaller.runAnnotatedField#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'ctor-assigned-annotated',
callerId: `Method:${FIXTURE_PATH}:CtorAssignedAnnotatedCaller.runCtorAssignedAnnotated#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'ctor-param-annotated',
callerId: `Method:${FIXTURE_PATH}:CtorParamAnnotatedCaller.runCtorParamAnnotated#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'param-property',
callerId: `Method:${FIXTURE_PATH}:ParamPropertyCaller.runParamProperty#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'call-result-receiver',
callerId: `Function:${FIXTURE_PATH}:runCallResultReceiver`,
targets: [MAKE_OUTER, OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'three-link-chain',
callerId: `Function:${FIXTURE_PATH}:runThreeLink`,
targets: [OUTER_CLASS, OUTER_INNER, INNER_MID, MID_COMPUTE],
resolution: 'resolves',
},
// ── Inference-typed fields (#2807) ────────────────────────────────────────
// Identical to `annotated-field-initializer` / `ctor-assigned-annotated`
// above except that the field carries no type annotation, so its type is
// inferred from the initializer. These two emitted NOTHING before #2807 —
// not even the first, plainly named link — because an untyped field had no
// type binding for the receiver fold to stand on.
{
name: 'inferred-field-initializer',
callerId: `Method:${FIXTURE_PATH}:InferredFieldCaller.runInferredField#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
{
name: 'ctor-assigned-inferred',
callerId: `Method:${FIXTURE_PATH}:CtorAssignedInferredCaller.runCtorAssignedInferred#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
// The assignment that types the field need not be in the constructor — the
// capture matches any `this.<field> = new …`, so a setter binds it too.
{
name: 'method-assigned-inferred',
callerId: `Method:${FIXTURE_PATH}:MethodAssignedInferredCaller.runMethodAssignedInferred#1`,
targets: [OUTER_INNER, INNER_COMPUTE],
resolution: 'resolves',
},
];
describe('TypeScript chained receiver calls by field-type form (#2807)', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-inferred-field-'));
writeFixtureRepo(repoDir, { [FIXTURE_PATH]: CHAINED_SOURCE });
// CALLS resolution is complete before the graph phases run and this pin
// reads nothing they produce (MRO, communities, processes), so skipping
// them narrows the run to the phase under test. Cost here is dominated by
// worker-pool startup, not by the phases, so this is about scope rather
// than speed.
result = await runPipelineFromRepo(repoDir, () => {}, { skipGraphPhases: true });
}, 120000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
/** Every CALLS target id emitted by one exact caller node, sorted. */
function callTargetsFrom(callerId: string): string[] {
return getRelationships(result, 'CALLS')
.filter((edge) => edge.rel.sourceId === callerId)
.map((edge) => edge.rel.targetId)
.sort();
}
function nodeExists(id: string): boolean {
return result.graph.getNode(id) !== undefined;
}
it('every receiver shape contributes exactly one caller node', () => {
const found = Object.fromEntries(RECEIVER_SHAPES.map((s) => [s.name, nodeExists(s.callerId)]));
expect(found).toEqual(Object.fromEntries(RECEIVER_SHAPES.map((s) => [s.name, true])));
});
// Exact set equality, not `arrayContaining`: a shape that started resolving
// something extra (or stopped resolving a link) has to show up in the diff.
for (const shape of RECEIVER_SHAPES.filter((s) => s.resolution === 'resolves')) {
it(`${shape.name}: every chain link becomes a CALLS edge`, () => {
expect(callTargetsFrom(shape.callerId)).toEqual([...shape.targets].sort());
});
}
// The source-strength tie-break, as an executable row rather than a comment.
// `private mistyped: Mismatch = new Outer()` matches BOTH the annotation
// pattern and the field constructor-inferred pattern added for #2807;
// `annotation` outranks `constructor-inferred` in `typeBindingStrength`, so
// the field must stay typed as `Mismatch` — which has no `inner` — and the
// caller must emit NO call edge. If the inferred binding ever wins instead,
// this is the only row in the file that notices: every other row would keep
// resolving, because for them the two sources agree.
//
// The load-bearing half of the assertion is the ABSENCE of `Outer.inner`:
// that is the edge a resolver would emit if the initializer had won.
//
// `Inner.compute` IS present, and deliberately pinned rather than filtered
// out. It does not come from the field at all — `Mismatch` has no `inner`, so
// the fold falls through to the hoisted branch in `typeOfMemberOnClass`,
// finds the module-level return-type binding `inner -> Inner` that
// `hoistTypeBindingsToModule` puts there, and types the NEXT position from
// it. Verified byte-identical on the pre-#2807 tree (same fixture, same
// single id), so it is a pre-existing property of the hoisted lookup and not
// something the field-initializer patterns introduced. Pinning the exact list
// rather than asserting "no Outer.inner" means a future change to either
// mechanism has to come through this row.
it('an annotated field beats its own initializer — the tie-break is by source strength', () => {
const callerId = `Method:${FIXTURE_PATH}:AnnotationBeatsInitializerCaller.runAnnotationBeatsInitializer#1`;
expect({ callerExists: nodeExists(callerId), calls: callTargetsFrom(callerId) }).toEqual({
callerExists: true,
calls: [INNER_COMPUTE],
});
});
// Boundary evidence: the initializer is not invisible to the resolver. Both
// twins of each pair emit the `new Outer()` constructor edge; only the
// annotated one turns it into a receiver type. So the missing step is the
// initializer -> field type binding, not the initializer itself.
it('the inferred field initializer IS resolved — only the receiver TYPE is lost', () => {
const initializerCalls = {
'annotated-field-initializer': callTargetsFrom(`Class:${FIXTURE_PATH}:AnnotatedFieldCaller`),
'inferred-field-initializer': callTargetsFrom(`Class:${FIXTURE_PATH}:InferredFieldCaller`),
'ctor-assigned-annotated': callTargetsFrom(
`Method:${FIXTURE_PATH}:CtorAssignedAnnotatedCaller.constructor#0`,
),
'ctor-assigned-inferred': callTargetsFrom(
`Method:${FIXTURE_PATH}:CtorAssignedInferredCaller.constructor#0`,
),
};
expect(initializerCalls).toEqual({
'annotated-field-initializer': [OUTER_CLASS],
'inferred-field-initializer': [OUTER_CLASS],
'ctor-assigned-annotated': [OUTER_CLASS],
'ctor-assigned-inferred': [OUTER_CLASS],
});
});
});