From b6aa45d93dc7eb253b536e5b60f2eb3bf7d3a5b6 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 3 Aug 2026 13:27:48 +0000 Subject: [PATCH] test(resolvers): pin inference-typed field receivers across eight languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2807 was filed against TypeScript, but the defect class is cross-language: "can a field whose type is inferred act as a call receiver". This measures all eight languages where the shape exists at all, in one table. The metric is parity with EACH LANGUAGE'S OWN CONTROL ROW, not "both chain links present". JavaScript, Python, Dart and PHP lose the second link (`Inner.compute`) even for a plain local, because nothing annotates `inner()`'s return type — a separate return-type-inference gap. Scoring against "both links" would have accused those four of a bug they do not have; scoring against their own control isolates the field-typing question cleanly. Recorded state: TypeScript, JavaScript, Python and Ruby now match their controls. Kotlin and PHP already did before #2807 and are pinned so the shared fold cannot regress them unnoticed — the languages that got receiver typing for free are precisely the ones nobody re-checks. Two rows stay pinned BROKEN, at their exact current value: Dart — real and narrow: the annotated control resolves, the inferred one does not. Its bindings are synthesized in dart/captures.ts rather than by a query, so the fix is its own change. Swift — blocked by a different defect found while measuring: with several classes each defining `run`, every `run`'s edges are attributed to the FIRST-declared one, which collects duplicates while its siblings — including the ANNOTATED control — collect none. Receiver typing cannot be measured there until that is fixed, and "fixing" it against this observable would be fitting to a broken measurement. Both gap rows carry a `callerExists` probe in the same assertion object, so an empty list can never read as "resolved fine, wrong node id", plus a whole-matrix guard that every language keeps a resolving control — that is what makes a gap row mean "broken" instead of "fixture never worked". Targets are deduplicated before comparison: Swift emits one edge more than once per call site, and edge multiplicity is a different question from whether the receiver typed at all. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) --- .../inferred-field-receiver-matrix.test.ts | 588 ++++++++++++++++++ 1 file changed, 588 insertions(+) create mode 100644 gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts diff --git a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts new file mode 100644 index 000000000..a497636e0 --- /dev/null +++ b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts @@ -0,0 +1,588 @@ +/** + * Cross-language matrix for #2807: can a class field whose type is INFERRED — + * from its initializer, or from a constructor call assigned to it — act as a + * call receiver? + * + * ── HOW TO READ A ROW ───────────────────────────────────────────────────────── + * + * Every row in every language runs the SAME statement shape, + * `.inner().compute(x)`, and only the receiver FORM varies. The + * question this file answers is not "did both links resolve" — it is **does an + * inference-typed field behave like that language's own control row**. + * + * That distinction is the whole design. Several languages lose the SECOND link + * (`Inner.compute`) even for a plain local, because they have no return-type + * annotation to carry the chain — JavaScript has none at all, and the Python, + * Dart and PHP fixtures here declare none. That is a separate + * return-type-inference gap and NOT what #2807 was about. Comparing an inferred + * field against the language's control row isolates the field-typing question + * from it; comparing against "both links present" would have falsely accused + * four languages and falsely cleared none. + * + * ── MEASURED STATE ──────────────────────────────────────────────────────────── + * + * language control inferred field (init) assigned field (this/self) + * ---------- ------------- ---------------------- -------------------------- + * TypeScript both links both links (#2807) both links (#2807) + * JavaScript Outer.inner Outer.inner (#2807) Outer.inner (already ok) + * Python Outer.inner n/a — no field decls Outer.inner (#2807) + * Ruby both links n/a — no field decls both links (#2807) + * Kotlin both links both links (was ok) n/a — needs a type + * PHP Outer.inner n/a — see below Outer.inner (was ok) + * Dart Outer.inner KNOWN GAP KNOWN GAP + * Swift both links KNOWN GAP n/a — needs a type + * + * Shapes marked n/a do not exist in that language: Python and Ruby have no + * field DECLARATIONS at all (a field is created by assignment, so only the + * assigned column is meaningful), PHP property initializers accept only + * constant expressions so `private $p = new Outer();` is not writable, and + * Kotlin/Swift cannot declare a stored property with neither a type nor an + * initializer, so their "assigned" shape is always annotated and already + * resolves through the annotation. + * + * Kotlin and PHP were already correct before #2807 and are pinned here so a + * change to the shared fold cannot regress them unnoticed — the two languages + * that got receiver typing for free are exactly the ones nobody would think to + * re-check. + * + * ── THE TWO REMAINING GAPS ──────────────────────────────────────────────────── + * + * Dart and Swift are pinned at their CURRENT broken value, deliberately not + * fixed in #2807: + * + * Dart — its type bindings are synthesized programmatically in + * `languages/dart/captures.ts`, not by a tree-sitter query, and its + * annotated control row DOES resolve, so the gap is real and narrow. + * Swift — blocked by a DIFFERENT defect. In a fixture with several classes + * each defining `run`, every `run`'s call edges are attributed to the + * FIRST-declared one: it collects duplicates while its siblings — the + * ANNOTATED control among them — collect nothing. Until that caller + * attribution is fixed, a Swift inferred-field row cannot be measured + * at all, and "fixing" receiver typing against it would be fitting to + * a broken observable. + * + * Both rows assert the empty value EXACTLY, with a `callerExists` probe in the + * same object so an empty list can never be read as "resolved fine, wrong node + * id". They are self-diffing: closing either gap fails this file with the newly + * resolved ids in the diff, which is the signal to move the row and correct the + * table above. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import type { PipelineResult } from './helpers.js'; + +/** One receiver form under test, with the exact CALLS targets it emits today. */ +interface Row { + readonly name: string; + /** Exact node id of the method holding the chained statement. */ + readonly callerId: string; + /** Every distinct CALLS target id, sorted. */ + readonly targets: readonly string[]; + readonly status: 'resolves' | 'known-gap'; +} + +interface LanguageCase { + readonly language: string; + readonly file: string; + readonly source: string; + readonly rows: readonly Row[]; +} + +// ── TypeScript ─────────────────────────────────────────────────────────────── +const TS_FILE = 'src/app.ts'; +const TS_SOURCE = `export class Inner { compute(v: number): number { return v * 2; } } +export class Outer { inner(): Inner { return new Inner(); } } +export class ControlLocal { run(x: number): number { const o = new Outer(); return o.inner().compute(x); } } +export class ControlTypedField { private p: Outer = new Outer(); run(x: number): number { return this.p.inner().compute(x); } } +export class InferredField { private p = new Outer(); run(x: number): number { return this.p.inner().compute(x); } } +export class AssignedField { private q; constructor() { this.q = new Outer(); } run(x: number): number { return this.q.inner().compute(x); } } +`; + +// ── JavaScript ─────────────────────────────────────────────────────────────── +const JS_FILE = 'src/app.js'; +const JS_SOURCE = `export class Inner { compute(v) { return v * 2; } } +export class Outer { inner() { return new Inner(); } } +export class ControlLocal { run(x) { const o = new Outer(); return o.inner().compute(x); } } +export class InferredField { p = new Outer(); run(x) { return this.p.inner().compute(x); } } +export class AssignedField { constructor() { this.q = new Outer(); } run(x) { return this.q.inner().compute(x); } } +`; + +// ── Python ─────────────────────────────────────────────────────────────────── +const PY_FILE = 'src/app.py'; +const PY_SOURCE = `class Inner: + def compute(self, v): + return v * 2 + + +class Outer: + def inner(self): + return Inner() + + +class ControlLocal: + def run(self, x): + o = Outer() + return o.inner().compute(x) + + +class AnnotatedField: + def __init__(self): + self.p: Outer = Outer() + + def run(self, x): + return self.p.inner().compute(x) + + +class AssignedField: + def __init__(self): + self.q = Outer() + + def run(self, x): + return self.q.inner().compute(x) +`; + +// ── Ruby ───────────────────────────────────────────────────────────────────── +const RB_FILE = 'src/app.rb'; +const RB_SOURCE = `class Inner + def compute(v) + v * 2 + end +end + +class Outer + def inner + Inner.new + end +end + +class ControlLocal + def run(x) + o = Outer.new + o.inner.compute(x) + end +end + +class AssignedField + def initialize + @q = Outer.new + end + + def run(x) + @q.inner.compute(x) + end +end +`; + +// ── Kotlin ─────────────────────────────────────────────────────────────────── +const KT_FILE = 'src/app.kt'; +const KT_SOURCE = `class Inner { + fun compute(v: Int): Int { return v * 2 } +} + +class Outer { + fun inner(): Inner { return Inner() } +} + +class ControlLocal { + fun run(x: Int): Int { + val o = Outer() + return o.inner().compute(x) + } +} + +class InferredField { + val p = Outer() + fun run(x: Int): Int { + return this.p.inner().compute(x) + } +} +`; + +// ── PHP ────────────────────────────────────────────────────────────────────── +const PHP_FILE = 'src/app.php'; +const PHP_SOURCE = `inner()->compute($x); } +} +class ControlTypedField { + private Outer $p; + public function __construct() { $this->p = new Outer(); } + public function run($x) { return $this->p->inner()->compute($x); } +} +class AssignedField { + private $q; + public function __construct() { $this->q = new Outer(); } + public function run($x) { return $this->q->inner()->compute($x); } +} +`; + +// ── Dart ───────────────────────────────────────────────────────────────────── +const DART_FILE = 'src/app.dart'; +const DART_SOURCE = `class Inner { + int compute(int v) { + return v * 2; + } +} + +class Outer { + Inner inner() { + return Inner(); + } +} + +class ControlLocal { + int run(int x) { + var o = Outer(); + return o.inner().compute(x); + } +} + +class ControlTypedField { + Outer p = Outer(); + int run(int x) { + return p.inner().compute(x); + } +} + +class InferredField { + var q = Outer(); + int run(int x) { + return q.inner().compute(x); + } +} + +class AssignedField { + var r; + AssignedField() { + r = Outer(); + } + int run(int x) { + return r.inner().compute(x); + } +} +`; + +// ── Swift ──────────────────────────────────────────────────────────────────── +const SWIFT_FILE = 'src/app.swift'; +const SWIFT_SOURCE = `class Inner { + func compute(_ v: Int) -> Int { return v * 2 } +} + +class Outer { + func inner() -> Inner { return Inner() } +} + +class ControlLocal { + func run(_ x: Int) -> Int { + let o = Outer() + return o.inner().compute(x) + } +} + +class InferredField { + let p = Outer() + func run(_ x: Int) -> Int { + return self.p.inner().compute(x) + } +} +`; + +const CASES: readonly LanguageCase[] = [ + { + language: 'typescript', + file: TS_FILE, + source: TS_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${TS_FILE}:ControlLocal.run#1`, + targets: [ + `Class:${TS_FILE}:Outer`, + `Method:${TS_FILE}:Inner.compute#1`, + `Method:${TS_FILE}:Outer.inner#0`, + ], + status: 'resolves', + }, + { + name: 'control-typed-field', + callerId: `Method:${TS_FILE}:ControlTypedField.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${TS_FILE}:InferredField.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${TS_FILE}:AssignedField.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'javascript', + file: JS_FILE, + source: JS_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${JS_FILE}:ControlLocal.run#1`, + targets: [`Class:${JS_FILE}:Outer`, `Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${JS_FILE}:InferredField.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${JS_FILE}:AssignedField.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'python', + file: PY_FILE, + source: PY_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${PY_FILE}:ControlLocal.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'control-annotated-field', + callerId: `Method:${PY_FILE}:AnnotatedField.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${PY_FILE}:AssignedField.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'ruby', + file: RB_FILE, + source: RB_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${RB_FILE}:ControlLocal.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${RB_FILE}:AssignedField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'kotlin', + file: KT_FILE, + source: KT_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${KT_FILE}:ControlLocal.run#1`, + targets: [`Method:${KT_FILE}:Inner.compute#1`, `Method:${KT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${KT_FILE}:InferredField.run#1`, + targets: [`Method:${KT_FILE}:Inner.compute#1`, `Method:${KT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'php', + file: PHP_FILE, + source: PHP_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${PHP_FILE}:ControlLocal.run#1`, + targets: [`Class:${PHP_FILE}:Outer`, `Method:${PHP_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'control-typed-field', + callerId: `Method:${PHP_FILE}:ControlTypedField.run#1`, + targets: [`Method:${PHP_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${PHP_FILE}:AssignedField.run#1`, + targets: [`Method:${PHP_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'dart', + file: DART_FILE, + source: DART_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${DART_FILE}:ControlLocal.run#1`, + targets: [`Class:${DART_FILE}:Outer`, `Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'control-typed-field', + callerId: `Method:${DART_FILE}:ControlTypedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${DART_FILE}:InferredField.run#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'assigned-field', + callerId: `Method:${DART_FILE}:AssignedField.run#1`, + targets: [], + status: 'known-gap', + }, + ], + }, + { + language: 'swift', + file: SWIFT_FILE, + source: SWIFT_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Function:${SWIFT_FILE}:ControlLocal.run#1`, + targets: [`Class:${SWIFT_FILE}:Outer`, `Function:${SWIFT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Function:${SWIFT_FILE}:InferredField.run#1`, + targets: [], + status: 'known-gap', + }, + ], + }, +]; + +describe('inference-typed field receivers across languages (#2807)', () => { + const results = new Map(); + + beforeAll(async () => { + for (const testCase of CASES) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-matrix-${testCase.language}-`)); + try { + writeFixtureRepo(dir, { [testCase.file]: testCase.source }); + // CALLS resolution is complete before the graph phases run and nothing + // here reads what they produce, so skipping them narrows each run to + // the phase under test. + results.set( + testCase.language, + await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }, 600000); + + /** + * Distinct CALLS target ids emitted by one exact caller, sorted. + * + * Deduplicated on purpose: Swift emits the same edge more than once for one + * call site, and edge MULTIPLICITY is a different question from whether the + * receiver typed at all. Deduplicating keeps this file measuring the one + * thing it claims to measure; a multiplicity regression belongs in a test + * that says so. + */ + function callTargets(language: string, callerId: string): string[] { + const result = results.get(language); + if (result === undefined) throw new Error(`no pipeline result for ${language}`); + return [ + ...new Set( + getRelationships(result, 'CALLS') + .filter((edge) => edge.rel.sourceId === callerId) + .map((edge) => edge.rel.targetId), + ), + ].sort(); + } + + function callerExists(language: string, callerId: string): boolean { + const result = results.get(language); + if (result === undefined) throw new Error(`no pipeline result for ${language}`); + return result.graph.getNode(callerId) !== undefined; + } + + for (const testCase of CASES) { + describe(testCase.language, () => { + // Every row's caller node must exist before any target assertion means + // anything: an id-scheme change or fixture drift would otherwise turn + // every row into a silently vacuous empty-vs-empty comparison — which is + // exactly how a "known gap" row rots into a passing lie. + it('every row has a live caller node', () => { + const found = Object.fromEntries( + testCase.rows.map((row) => [row.name, callerExists(testCase.language, row.callerId)]), + ); + expect(found).toEqual(Object.fromEntries(testCase.rows.map((row) => [row.name, true]))); + }); + + for (const row of testCase.rows.filter((r) => r.status === 'resolves')) { + it(`${row.name}: resolves`, () => { + expect(callTargets(testCase.language, row.callerId)).toEqual([...row.targets].sort()); + }); + } + + const gaps = testCase.rows.filter((r) => r.status === 'known-gap'); + if (gaps.length > 0) { + it(`KNOWN GAP: inference-typed field receivers emit no CALLS edges`, () => { + const observed = Object.fromEntries( + gaps.map((row) => [row.name, callTargets(testCase.language, row.callerId)]), + ); + expect(observed).toEqual(Object.fromEntries(gaps.map((row) => [row.name, []]))); + }); + } + }); + } + + // A whole-matrix guard: the per-language blocks above would all still pass if + // a language were quietly deleted from CASES, or if a row lost its control. + // Every language must keep at least one control row that resolves — that is + // what makes its gap rows mean "broken" rather than "fixture never worked". + it('every language keeps a resolving control row', () => { + const controls = Object.fromEntries( + CASES.map((testCase) => [ + testCase.language, + testCase.rows.some((row) => row.name.startsWith('control') && row.status === 'resolves'), + ]), + ); + expect(controls).toEqual(Object.fromEntries(CASES.map((c) => [c.language, true]))); + }); +});