From 0df91b772e2f806f3996fdf55b5cb15d6acadced Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 21 May 2026 10:32:49 +0100 Subject: [PATCH] test(ingestion): add regression coverage for issue #1358 singleton sub-cases 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, NOTED): the class-instance singleton (`export const fooService = new FooService();`) and the factory-pattern singleton (`export const fooService = makeFooService();`). Pre-plan investigation (per docs/plans/2026-05-21-002 § "Pre-Plan Investigation Task (T1)") confirmed Outcome A for both patterns — they already resolve end-to-end through scope-resolution's `@type-binding.constructor` capture (languages/typescript/query.ts:489-511) + `propagateImportedReturnTypes` chain-follow (scope-resolution/passes/imported-return-types.ts:114) + receiver-bound Case 4 simple typeBinding lookup (receiver-bound-calls.ts:625). The mechanism was wired correctly before this session; the regression-net wasn't. This test pins the behavior: - Pattern 1: `caller → FooService.getUser` CALLS edge with confidence 0.85 and reason 'import-resolved' - Pattern 2: same edge shape via factory chain-follow (the `@type-binding.alias` capture for `const u = find()` style) Both assertions use exact `.toEqual([{...}])` shape pinning so a future regression that targets a phantom Method node, emits at lower confidence, or drops the cross-file import-resolved reason fails loudly. Verification: 5/5 pass, 127/127 in targeted regression sweep including object-literal-owner-resolution.test.ts, ast-helpers-object-literal- binding.test.ts, has-method.test.ts, and cross-file-binding.test.ts. No production code change. The class methods get a class-qualified node id (`Method:src/service.ts:FooService.getUser#1`) distinguishing them from same-name methods on other classes — distinct from the bare-name node id shape PR #1718's object-literal case uses. --- ...e-and-factory-singleton-resolution.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 gitnexus/test/integration/class-instance-and-factory-singleton-resolution.test.ts 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 new file mode 100644 index 000000000..ed1fb2b16 --- /dev/null +++ b/gitnexus/test/integration/class-instance-and-factory-singleton-resolution.test.ts @@ -0,0 +1,181 @@ +/** + * 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', + }, + ]); + }); +});