diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 57ad0b5f7..c2b537352 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts index f8f7302fa..ccc45d022 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts @@ -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. = 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)) { diff --git a/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts b/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts new file mode 100644 index 000000000..f10a10c1a --- /dev/null +++ b/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts @@ -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(), + }); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts b/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts new file mode 100644 index 000000000..a2fcd2ba9 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts @@ -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, `.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. = 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. = 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], + }); + }); +});