mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
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:
parent
d7a659b0a7
commit
6869504408
4 changed files with 760 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue