From c8e573bc35a9856ce79e9eb48491a3adf8eee2e6 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 21 May 2026 11:43:18 +0100 Subject: [PATCH] test(resolvers): add class-instance + factory-pattern singleton coverage for TS/JS (issue #1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining sub-cases of issue #1358 surfaced by PR #1718's adversarial review (Finding 4). PR #1718 fixed object-literal-shorthand singletons (`export const fooService = { getUser() {} }`); this commit adds parallel coverage for the two other singleton shapes that resolve through the existing scope-resolution chain: // Pattern 1 — class-instance singleton export class FooService { getUser(id) { ... } } export const fooService = new FooService(); // Pattern 2 — factory-pattern singleton export class FooService { getUser(id) { ... } } export function makeFooService() { return new FooService(); } export const fooService = makeFooService(); Pre-plan investigation (per local plan docs/plans/2026-05-21-002 § "Pre-Plan Investigation Task (T1)") confirmed Outcome A — both patterns already resolve end-to-end through: - `@type-binding.constructor` capture (languages/{typescript,javascript}/ query.ts) seeds `fooService → FooService` at parse time - `propagateImportedReturnTypes` (scope-resolution/passes/ imported-return-types.ts:114) mirrors the typeBinding cross-file - Receiver-bound Case 4 simple typeBinding lookup (scope-resolution/passes/receiver-bound-calls.ts:625) MRO-walks FooService and emits the CALLS edge to getUser Tests added per language × pattern (5 each, 10 total): - node existence (Class, Method, Function, Const, plus Function for the factory pattern's `makeFooService`) - HAS_METHOD edge from class to method (class-instance variant) - CALLS edge from caller to `getUser` with `targetFilePath: 'src/service.{ts,js}'`, `reason: 'import-resolved'`, `confidence: 0.85` — exact `.toEqual([{...}])` shape pinning so a regression that emits at lower confidence or drops the cross-file reason fails loudly Fixtures placed under the existing `test/fixtures/lang-resolution/` convention. Tests appended to `test/integration/resolvers/{typescript,javascript}.test.ts`, matching the in-file pattern of every other resolver scenario. Also supersedes and removes the standalone `test/integration/class-instance-and-factory-singleton-resolution.test.ts` introduced earlier in this PR session (`0df91b77`) — the proper home for language-resolver scenarios is the per-language resolver test file alongside similar fixtures (`javascript-self-this-resolution`, `javascript-cross-file`, `typescript-tsconfig-paths`, etc.). One canonical location for the scenario, not two. Verification: 10/10 new singleton tests pass; 297/297 full TS+JS resolver suite pass (no regression in any existing resolver test). --- .../src/consumer.js | 9 + .../src/service.js | 11 ++ .../src/consumer.js | 9 + .../src/service.js | 18 ++ .../src/consumer.ts | 5 + .../src/service.ts | 7 + .../src/consumer.ts | 5 + .../src/service.ts | 11 ++ ...e-and-factory-singleton-resolution.test.ts | 181 ------------------ .../integration/resolvers/javascript.test.ts | 98 ++++++++++ .../integration/resolvers/typescript.test.ts | 97 ++++++++++ 11 files changed, 270 insertions(+), 181 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts delete mode 100644 gitnexus/test/integration/class-instance-and-factory-singleton-resolution.test.ts diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js new file mode 100644 index 000000000..9c51372f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js @@ -0,0 +1,9 @@ +import { fooService } from './service.js'; + +/** + * @param {string} id + * @returns {string} + */ +export function caller(id) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js new file mode 100644 index 000000000..079e643f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js @@ -0,0 +1,11 @@ +export class FooService { + /** + * @param {string} id + * @returns {string} + */ + getUser(id) { + return id; + } +} + +export const fooService = new FooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js new file mode 100644 index 000000000..9c51372f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js @@ -0,0 +1,9 @@ +import { fooService } from './service.js'; + +/** + * @param {string} id + * @returns {string} + */ +export function caller(id) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js new file mode 100644 index 000000000..c0472b396 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js @@ -0,0 +1,18 @@ +export class FooService { + /** + * @param {string} id + * @returns {string} + */ + getUser(id) { + return id; + } +} + +/** + * @returns {FooService} + */ +export function makeFooService() { + return new FooService(); +} + +export const fooService = makeFooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts new file mode 100644 index 000000000..7d1298ec7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts @@ -0,0 +1,5 @@ +import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts new file mode 100644 index 000000000..e8a729ac9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts @@ -0,0 +1,7 @@ +export class FooService { + getUser(id: string) { + return id; + } +} + +export const fooService = new FooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts new file mode 100644 index 000000000..7d1298ec7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts @@ -0,0 +1,5 @@ +import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts new file mode 100644 index 000000000..89a70d553 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts @@ -0,0 +1,11 @@ +export class FooService { + getUser(id: string) { + return id; + } +} + +export function makeFooService(): FooService { + return new FooService(); +} + +export const fooService = makeFooService(); diff --git a/gitnexus/test/integration/class-instance-and-factory-singleton-resolution.test.ts b/gitnexus/test/integration/class-instance-and-factory-singleton-resolution.test.ts deleted file mode 100644 index ed1fb2b16..000000000 --- a/gitnexus/test/integration/class-instance-and-factory-singleton-resolution.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Regression coverage for issue #1358's class-instance and factory-pattern - * singleton sub-cases. PR #1718 closed the object-literal-shorthand case - * (`export const fooService = { getUser() {} }`). This file covers the - * other two singleton shapes: - * - * // Pattern 1 — class-instance singleton - * export class FooService { getUser(id) {...} } - * export const fooService = new FooService(); - * - * // Pattern 2 — factory-pattern singleton - * export class FooService { getUser(id) {...} } - * export function makeFooService() { return new FooService(); } - * export const fooService = makeFooService(); - * - * Both already resolve end-to-end via scope-resolution's - * `@type-binding.constructor` capture (TS query) + - * `propagateImportedReturnTypes` chain-follow (cross-file mirror) + - * receiver-bound Case 4 (simple typeBinding lookup). This test pins that - * behavior so a future refactor of any of those three mechanisms cannot - * silently regress either pattern. - * - * Origin: PR #1718 review Finding 4 (NOTED, deferred). T1 pre-plan - * investigation per docs/plans/2026-05-21-002-feat-pr1718-followups-class- - * instance-and-label-normalization-plan.md confirmed Outcome A — both - * patterns already work; this is the regression-net. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - getRelationships, - getNodesByLabel, - runPipelineFromRepo, - type PipelineResult, -} from './resolvers/helpers.js'; -import { generateId } from '../../src/lib/utils.js'; - -function writeFixture(files: Record): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-singleton-')); - for (const [rel, content] of Object.entries(files)) { - const full = path.join(root, rel); - fs.mkdirSync(path.dirname(full), { recursive: true }); - fs.writeFileSync(full, content); - } - return root; -} - -function removeFixture(root: string): void { - fs.rmSync(root, { recursive: true, force: true }); -} - -const CONSUMER_TS = `import { fooService } from './service'; - -export function caller(id: string) { - return fooService.getUser(id); -} -`; - -// Class methods carry a class-qualified node id (e.g. `FooService.getUser`) -// to distinguish them from same-name methods on other classes. Object-literal -// methods (per PR #1718) use the bare name because they have no class owner. -const EXPECTED_METHOD_NODE_ID = generateId('Method', 'src/service.ts:FooService.getUser#1'); - -// ── Pattern 1: class-instance singleton ───────────────────────────────────── - -describe('class-instance singleton resolution (issue #1358 sub-case 2)', () => { - let repoRoot: string; - let result: PipelineResult; - - beforeAll(async () => { - repoRoot = writeFixture({ - 'src/service.ts': `export class FooService { - getUser(id: string) { - return id; - } -} - -export const fooService = new FooService(); -`, - 'src/consumer.ts': CONSUMER_TS, - }); - result = await runPipelineFromRepo(repoRoot, () => undefined, { - skipGraphPhases: true, - skipWorkers: true, - }); - }, 60000); - - afterAll(() => removeFixture(repoRoot)); - - it('emits Class:FooService, Method:getUser, Function:caller, Const:fooService exactly once', () => { - expect(getNodesByLabel(result, 'Class').filter((n) => n === 'FooService').length).toBe(1); - expect(getNodesByLabel(result, 'Method').filter((n) => n === 'getUser').length).toBe(1); - expect(getNodesByLabel(result, 'Function').filter((n) => n === 'caller').length).toBe(1); - expect(getNodesByLabel(result, 'Const').filter((n) => n === 'fooService').length).toBe(1); - }); - - it('emits HAS_METHOD edge from FooService class to getUser', () => { - const hasMethod = getRelationships(result, 'HAS_METHOD'); - const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); - expect(fromClass).toEqual(['getUser']); - }); - - it('emits CALLS edge caller → FooService.getUser with confidence 0.85 and reason import-resolved', () => { - const calls = getRelationships(result, 'CALLS'); - const projected = calls - .filter((e) => e.source === 'caller' && e.target === 'getUser') - .map((e) => ({ - targetId: e.rel.targetId, - confidence: e.rel.confidence, - reason: e.rel.reason, - })); - - expect(projected).toEqual([ - { - targetId: EXPECTED_METHOD_NODE_ID, - confidence: 0.85, - reason: 'import-resolved', - }, - ]); - }); -}); - -// ── Pattern 2: factory-pattern singleton ──────────────────────────────────── - -describe('factory-pattern singleton resolution (issue #1358 sub-case 3)', () => { - let repoRoot: string; - let result: PipelineResult; - - beforeAll(async () => { - repoRoot = writeFixture({ - 'src/service.ts': `export class FooService { - getUser(id: string) { - return id; - } -} - -export function makeFooService(): FooService { - return new FooService(); -} - -export const fooService = makeFooService(); -`, - 'src/consumer.ts': CONSUMER_TS, - }); - result = await runPipelineFromRepo(repoRoot, () => undefined, { - skipGraphPhases: true, - skipWorkers: true, - }); - }, 60000); - - afterAll(() => removeFixture(repoRoot)); - - it('emits Function:makeFooService alongside the class and consumer nodes', () => { - expect(getNodesByLabel(result, 'Function').filter((n) => n === 'makeFooService').length).toBe( - 1, - ); - expect(getNodesByLabel(result, 'Function').filter((n) => n === 'caller').length).toBe(1); - expect(getNodesByLabel(result, 'Const').filter((n) => n === 'fooService').length).toBe(1); - }); - - it('resolves caller.fooService.getUser to FooService.getUser via factory chain-follow', () => { - const calls = getRelationships(result, 'CALLS'); - const projected = calls - .filter((e) => e.source === 'caller' && e.target === 'getUser') - .map((e) => ({ - targetId: e.rel.targetId, - confidence: e.rel.confidence, - reason: e.rel.reason, - })); - - expect(projected).toEqual([ - { - targetId: EXPECTED_METHOD_NODE_ID, - confidence: 0.85, - reason: 'import-resolved', - }, - ]); - }); -}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index ccb4df618..e7664de8f 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -541,3 +541,101 @@ describe('JavaScript Child extends Parent — inherited method resolution (SM-9) expect(parentMethodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// Issue #1358: class-instance singleton (`export const x = new C()`) +// PR #1718 closed the object-literal-shorthand sub-case; this fixture covers +// the class-instance sub-case for JavaScript. Same resolution chain as TS but +// the receiver type comes from the `new ClassName()` initializer (no JSDoc +// annotation needed — the @type-binding.constructor capture handles it). +// --------------------------------------------------------------------------- + +describe('JavaScript class-instance singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-class-instance-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, getUser method, caller function, fooService Const', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('emits HAS_METHOD edge from FooService to getUser', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); + expect(fromClass).toEqual(['getUser']); + }); + + it('resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.js', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #1358: factory-pattern singleton (`export const x = makeC()`) +// Tests the @type-binding.alias chain-follow for JS — fooService aliases the +// return of makeFooService(), whose JSDoc @returns {FooService} ties the chain +// back to the class. Resolution propagates cross-file via +// propagateImportedReturnTypes followChainPostFinalize. +// --------------------------------------------------------------------------- + +describe('JavaScript factory-pattern singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-factory-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, makeFooService function, fooService Const, caller function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Function')).toContain('makeFooService'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('resolves caller.fooService.getUser() through the factory chain to FooService.getUser', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.js', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 8b6e50d20..dcaca2d57 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -2936,3 +2936,100 @@ export function createUtf8User(): void { } }); }); + +// --------------------------------------------------------------------------- +// Issue #1358: class-instance singleton (`export const x = new C()`) +// PR #1718 closed the object-literal-shorthand sub-case; this fixture covers +// the class-instance sub-case. Resolution chain: @type-binding.constructor +// (TS query) → propagateImportedReturnTypes (cross-file mirror) → +// receiver-bound Case 4 (simple typeBinding) → MRO walk. +// --------------------------------------------------------------------------- + +describe('TypeScript class-instance singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-class-instance-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, getUser method, caller function, fooService Const', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('emits HAS_METHOD edge from FooService to getUser', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); + expect(fromClass).toEqual(['getUser']); + }); + + it('resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.ts', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #1358: factory-pattern singleton (`export const x = makeC()`) +// Tests the @type-binding.alias chain-follow path through +// propagateImportedReturnTypes (followChainPostFinalize) — fooService aliases +// makeFooService's return type, which the constructor seeds as FooService. +// --------------------------------------------------------------------------- + +describe('TypeScript factory-pattern singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-factory-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, makeFooService function, fooService Const, caller function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Function')).toContain('makeFooService'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('resolves caller.fooService.getUser() through the factory chain to FooService.getUser', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.ts', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +});