From e9531ceede5f056af1a1267df7bfac5600bc9041 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 21 May 2026 08:02:06 +0100 Subject: [PATCH] fix(ingestion): bridge object-literal value receivers in scope-resolution (PR #1718 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses adversarial production-readiness review on PR #1718 / issue #1358: - F1 (caller resolution) — setting `ownerId` on object-literal method symbols alone is not sufficient; the scope-resolution receiver-bound resolver only consults class-like or type-annotated bindings, so lowercase value receivers (`export const fooService = {...}; fooService.getUser(...)`) never reach the owner-indexed lookup. Adds a Case 5 value-receiver bridge in receiver-bound-calls.ts that resolves the receiver name as a Const/Variable binding, translates its def to the canonical graph node id, and emits the CALLS edge via the owner-indexed method registry. - F2 (boundary guard) — rewrites findObjectLiteralBindingInfo as an explicit two-phase AST walk: Phase A tracks object-literal depth (returns null for nested literals and pre-declarator function/class boundaries — IIFE patterns); Phase B walks the declarator's ancestors and rejects function, class, and block-statement containers (if / for / while / try / catch / switch / etc.) before reaching program/export_statement. Prevents false HAS_METHOD edges for locally-scoped or block-scoped object literals. - F4 — drops the dead `ownerName` field from ObjectLiteralBindingInfo. Constraint: TS/JS are scope-resolution migrated per RFC #909; the legacy Call-Resolution DAG (call-processor.ts) is intentionally left untouched. Tests: - test/integration/ast-helpers-object-literal-binding.test.ts (13 cases) — pins helper semantics: happy paths, function/arrow/class-ctor boundaries, nested literals, block scope (if / for-of / try), IIFE, assignment expressions without declarator. - test/integration/object-literal-owner-resolution.test.ts (9 cases) — drives the full pipeline against an on-disk fixture: sequential CALLS edge emission (issue #1358 proof), worker-mode parity, negative local binding, and nested-literal attribution boundary. Full sweep: 2958/2958 integration + 6056/6056 unit tests pass. --- .../src/core/ingestion/parsing-processor.ts | 2 +- .../graph-bridge/node-lookup.ts | 8 +- .../passes/receiver-bound-calls.ts | 66 ++++- .../scope-resolution/scope/walkers.ts | 49 ++++ .../src/core/ingestion/utils/ast-helpers.ts | 95 +++++-- .../core/ingestion/workers/parse-worker.ts | 4 +- ...ast-helpers-object-literal-binding.test.ts | 181 +++++++++++++ .../object-literal-owner-resolution.test.ts | 250 ++++++++++++++++++ 8 files changed, 635 insertions(+), 20 deletions(-) create mode 100644 gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts create mode 100644 gitnexus/test/integration/object-literal-owner-resolution.test.ts diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6b29f5e43..6465a9782 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -790,7 +790,7 @@ const processParsingSequential = async ( returnType: methodProps.returnType as string | undefined, declaredType, templateArguments: classTemplateArguments, - ownerId: enclosingClassId ?? undefined, + ownerId: enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? undefined, qualifiedName: qualifiedTypeName, }); diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index fd8c3cf23..8c29f8f2c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -159,6 +159,12 @@ export function isLinkableLabel(label: NodeLabel): boolean { // ACCESSES edges target field nodes (e.g. `user.name = "x"` → // ACCESSES edge to User's `name` Variable/Property node). label === 'Variable' || - label === 'Property' + label === 'Property' || + // Const is linkable so the value-receiver-owner bridge in + // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution + // `Variable` def for `export const fooService = {...}` to the canonical + // `Const:filePath:name` graph node id, against which object-literal + // method symbols register their `ownerId` (PR #1718 / issue #1358). + label === 'Const' ); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 430d83c2d..39cb8ce23 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -21,6 +21,11 @@ * but not a namespace prefix → compound resolver * 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → * MRO walk + `findOwnedMember` + * 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable` + * whose `nodeId` is referenced as an `ownerId` in `model.methods` + * (object-literal services). Last-resort fallback for lowercase + * receivers with no class-like or type-binding match. Mirrors + * the legacy DAG bridge in `call-processor.ts`. * * Reordering or merging cases changes resolution semantics. * @@ -46,11 +51,12 @@ import { findExportedDef, findOwnedMember, findReceiverTypeBinding, + findValueBindingInScope, isClassLike, } from '../scope/walkers.js'; import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; -import { resolveDefGraphId } from '../graph-bridge/ids.js'; +import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization, @@ -706,6 +712,64 @@ export function emitReceiverBoundCalls( } } } + + // ── Case 5: value-receiver bridge (object-literal services) ── + // When prior cases couldn't resolve the receiver as a class or + // type binding, fall back to value-binding resolution. Covers: + // + // export const fooService = { getUser(id) {...} }; + // import { fooService } from './service'; + // fooService.getUser(id); // ← resolve here + // + // `fooService` is a `Const`/`Variable` (not class-like, no typeBinding + // for unannotated literals), so Cases 2-4 skip it. Scope-resolution + // defs for non-class values carry a synthetic id, so we translate to + // the canonical graph node ID via `resolveDefGraphId` before owner- + // indexed lookup — the parser writes the graph node ID as `ownerId` + // on the method symbol-table entry to match. + // + // Object-literal methods do not carry a `qualifiedName` (no class + // owner to seed it), so the picked def cannot round-trip through + // `tryEmitEdge` → `resolveDefGraphId`. We emit directly using + // `picked.nodeId` (already the canonical graph node id, written by + // the legacy parse phase). + const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes); + if (valueDef !== undefined) { + const ownerGraphId = + resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId; + const picked = pickOverload(ownerGraphId, memberName, site, model, provider); + if (picked === OVERLOAD_AMBIGUOUS) { + handledSites.add(siteKey); + continue; + } + if (picked !== undefined) { + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + if (callerGraphId !== undefined) { + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : picked.filePath !== parsed.filePath + ? 'import-resolved' + : 'global'; + const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + const dedupKey = `CALLS:${callerGraphId}->${picked.nodeId}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (!seen.has(dedupKey)) { + seen.add(dedupKey); + graph.addRelationship({ + id: `rel:${dedupKey}`, + sourceId: callerGraphId, + targetId: picked.nodeId, + type: 'CALLS', + confidence, + reason, + }); + emitted++; + } + handledSites.add(siteKey); + continue; + } + } + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 6e087d4f7..1b5de4516 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -211,6 +211,55 @@ export function findClassBindingInScope( return undefined; } +/** + * Look up a value-binding (non-class-like, non-callable) by name in + * the given scope's chain. Used by the value-receiver-owner bridge + * for object-literal services such as: + * + * export const fooService = { getUser(id) {...} }; + * + * where `fooService` is a `Const`/`Variable` whose `nodeId` is the + * `ownerId` of the member method but where neither `findClassBindingInScope` + * (rejects non-class-like) nor `findReceiverTypeBinding` (no typeBinding for + * an unannotated literal) finds it. Returns the first non-class-like, + * non-callable binding match. + * + * Mirrors `findClassBindingInScope` exactly; only the accepted def-type + * predicate differs. + */ +export function findValueBindingInScope( + startScope: ScopeId, + receiverName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + let currentId: ScopeId | null = startScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return undefined; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return undefined; + + const isValueLike = (t: string): boolean => + !isClassLike(t) && t !== 'Function' && t !== 'Method' && t !== 'Constructor'; + + const localBindings = scope.bindings.get(receiverName); + if (localBindings !== undefined) { + for (const b of localBindings) { + if (isValueLike(b.def.type)) return b.def; + } + } + + const importedBindings = lookupBindingsAt(currentId, receiverName, scopes); + for (const b of importedBindings) { + if (isValueLike(b.def.type)) return b.def; + } + + currentId = scope.parent; + } + return undefined; +} + /** * Look up a callable (Function/Method/Constructor) by name in the * given scope's chain. Uses the dual-source pattern (scope.bindings + diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 0bf3573ad..98e795686 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -414,9 +414,32 @@ export const findEnclosingClassInfo = ( /** Object literal binding info for TS/JS shorthand methods. */ export interface ObjectLiteralBindingInfo { ownerId: string; - ownerName: string; } +/** + * Block-statement AST types that disqualify an object-literal binding from + * carrying a HAS_METHOD edge. A `const` declared inside one of these is block- + * scoped and cannot be imported, so attributing methods to it would create + * false-positive cross-file edges. + */ +const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([ + 'statement_block', + 'if_statement', + 'else_clause', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'while_statement', + 'do_statement', + 'try_statement', + 'catch_clause', + 'finally_clause', + 'switch_statement', + 'switch_case', + 'switch_default', + 'with_statement', +]); + /** * Find the file-scope variable that owns an object literal method definition. * @@ -428,41 +451,81 @@ export interface ObjectLiteralBindingInfo { * not inside a class container. Without this fallback, ingestion emits a * top-level `Method` node but no edge from the exported `service` value to * that method, so impact queries cannot discover `service.load`. + * + * Two-phase walk: + * Phase A walks up from `node` tracking how many `object` ancestors we + * cross. The first `variable_declarator` reached with `objectDepth >= 1` + * is the candidate owner — unless `objectDepth > 1` (the method belongs + * to a nested object literal; we return null rather than misattribute + * to the outer binding). Hitting a function/class container before the + * declarator returns null (catches IIFE-wrapped literals). + * Phase B walks the declarator's own ancestors. Any function or class + * ancestor before reaching `program`/`export_statement` returns null + * (catches `const` declared inside a function body). Any block-statement + * ancestor also returns null (catches block-scoped declarations inside + * top-level `if`/`for`/`try`/etc., which cannot be imported). */ export const findObjectLiteralBindingInfo = ( node: SyntaxNode, filePath: string, ): ObjectLiteralBindingInfo | null => { + // ── Phase A: walk up from node, count `object` ancestors, find declarator let current: SyntaxNode | null = node; - let sawObjectLiteral = false; + let objectDepth = 0; + let declarator: SyntaxNode | null = null; while (current) { - if (current.type === 'object') sawObjectLiteral = true; - - if (sawObjectLiteral && current.type === 'variable_declarator') { - const nameNode = current.childForFieldName?.('name'); - if (!nameNode || nameNode.type !== 'identifier') return null; - - const declaration = current.parent; - const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const'; - return { - ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`), - ownerName: nameNode.text, - }; + if (current.type === 'object') { + objectDepth += 1; + } + + if (current.type === 'variable_declarator' && objectDepth >= 1) { + if (objectDepth > 1) { + // Method belongs to a nested object literal; safe under-approximation. + return null; + } + declarator = current; + break; } - // Stop at a function/class boundary before finding an owning object literal. if ( current !== node && (FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type)) ) { + // Function/class container encountered before owning declarator + // (e.g. IIFE-wrapped object literal). Bail out. return null; } current = current.parent; } - return null; + if (!declarator) return null; + + // ── Phase B: declarator must live at file scope (program / export_statement) + // with no function, class, or block-statement ancestor in between. + let anc: SyntaxNode | null = declarator.parent; + while (anc) { + if (anc.type === 'program' || anc.type === 'export_statement') { + break; + } + if (FUNCTION_NODE_TYPES.has(anc.type) || CLASS_CONTAINER_TYPES.has(anc.type)) { + return null; + } + if (BLOCK_SCOPE_BOUNDARY_TYPES.has(anc.type)) { + return null; + } + anc = anc.parent; + } + + const nameNode = declarator.childForFieldName?.('name'); + if (!nameNode || nameNode.type !== 'identifier') return null; + + const declaration = declarator.parent; + const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const'; + return { + ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`), + }; }; /** Convenience wrapper: returns just the class ID string (backward compat). */ diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index b84c1e59d..56b50399b 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -2327,7 +2327,9 @@ const processFileGroup = ( ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 ? { templateArguments: classTemplateArguments } : {}), - ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), + ...((enclosingClassId ?? objectLiteralOwnerInfo?.ownerId) + ? { ownerId: (enclosingClassId ?? objectLiteralOwnerInfo?.ownerId) as string } + : {}), visibility: methodProps.visibility as string | undefined, isStatic: methodProps.isStatic as boolean | undefined, isReadonly: methodProps.isReadonly as boolean | undefined, diff --git a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts new file mode 100644 index 000000000..99f3700aa --- /dev/null +++ b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts @@ -0,0 +1,181 @@ +/** + * Integration tests for `findObjectLiteralBindingInfo`. + * + * Drives the helper against real tree-sitter ASTs (TypeScript) and pins the + * Phase A / Phase B boundary semantics from the PR #1718 production-readiness + * review (U1): + * - happy path: file-scope export const / const / export var → returns binding + * - local-inside-function / arrow / class-constructor → null + * - nested object literal → null (safe under-approximation) + * - block-scoped declaration (if / for body) → null + * - IIFE-wrapped object literal → null + * - assignment without declarator → null (no throw) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { findObjectLiteralBindingInfo } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { generateId } from '../../src/lib/utils.js'; + +let parser: Parser; + +beforeAll(async () => { + parser = await loadParser(); + await loadLanguage(SupportedLanguages.TypeScript, 'fixture.ts'); +}); + +/** Locate every method_definition AST node by name. */ +function findMethodNodes(root: Parser.SyntaxNode, methodName: string): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const visit = (node: Parser.SyntaxNode) => { + if (node.type === 'method_definition') { + const name = node.childForFieldName('name'); + if (name?.text === methodName) out.push(node); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) visit(child); + } + }; + visit(root); + return out; +} + +function parseTs(code: string): Parser.Tree { + return parser.parse(code); +} + +describe('findObjectLiteralBindingInfo — happy paths', () => { + it('exported const + shorthand method → owner binding', () => { + const tree = parseTs(`export const fooService = { async getUser(id: string) { return id; } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'getUser'); + expect(methodNode).toBeDefined(); + const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts'); + expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') }); + }); + + it('bare file-scope const → owner binding', () => { + const tree = parseTs(`const fooService = { getUser(id: string) { return id; } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'getUser'); + const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts'); + expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') }); + }); + + it('exported var (variable_declaration) → Variable label', () => { + const tree = parseTs(`export var legacyService = { run() {} };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + const result = findObjectLiteralBindingInfo(methodNode, 'src/legacy.ts'); + expect(result).toEqual({ ownerId: generateId('Variable', 'src/legacy.ts:legacyService') }); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: container boundaries', () => { + it('local const inside exported function → null', () => { + const tree = parseTs(` + export function processAll() { + const handler = { run(x: string) { return x; } }; + return handler; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('local const inside exported arrow function → null', () => { + const tree = parseTs(` + export const make = () => { + const h = { run() {} }; + return h; + }; + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('local const inside class constructor → null', () => { + const tree = parseTs(` + export class C { + constructor() { + const h = { run() {} }; + void h; + } + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/c.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: nested literals', () => { + it('inner method of nested literal → null (safe under-approximation)', () => { + const tree = parseTs(`export const s = { nested: { method() {} } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'method'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/s.ts')).toBe(null); + }); + + it('top-level method alongside nested literal still binds to outer', () => { + const tree = parseTs(`export const s = { nested: { inner() {} }, outer() {} };`); + const [outerNode] = findMethodNodes(tree.rootNode, 'outer'); + expect(findObjectLiteralBindingInfo(outerNode, 'src/s.ts')).toEqual({ + ownerId: generateId('Const', 'src/s.ts:s'), + }); + const [innerNode] = findMethodNodes(tree.rootNode, 'inner'); + expect(findObjectLiteralBindingInfo(innerNode, 'src/s.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: block scope', () => { + it('declared inside top-level if-block → null', () => { + const tree = parseTs(` + const cond = true; + if (cond) { + const handler = { run() {} }; + void handler; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('declared inside for-of body → null', () => { + const tree = parseTs(` + const arr = [1, 2]; + for (const _i of arr) { + const h = { run() {} }; + void h; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('declared inside try-block → null', () => { + const tree = parseTs(` + try { + const h = { run() {} }; + void h; + } catch {} + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: IIFE and assignment', () => { + it('IIFE-wrapped object literal → null', () => { + const tree = parseTs(`export const x = (() => ({ m() {} }))();`); + const [methodNode] = findMethodNodes(tree.rootNode, 'm'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/x.ts')).toBe(null); + }); + + it('assignment expression (no variable_declarator) → null without throwing', () => { + const tree = parseTs(` + let y: any; + y = { m() {} }; + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'm'); + expect(() => findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).not.toThrow(); + expect(findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).toBe(null); + }); +}); diff --git a/gitnexus/test/integration/object-literal-owner-resolution.test.ts b/gitnexus/test/integration/object-literal-owner-resolution.test.ts new file mode 100644 index 000000000..72a9afde1 --- /dev/null +++ b/gitnexus/test/integration/object-literal-owner-resolution.test.ts @@ -0,0 +1,250 @@ +/** + * Integration tests for PR #1718 production-readiness review (U4). + * + * Proves the bug fix for issue #1358 end-to-end: + * + * export const fooService = { getUser(id: string) { return id; } }; + * // consumer.ts + * import { fooService } from './service'; + * export function caller(id: string) { return fooService.getUser(id); } + * + * After this PR, the full ingestion pipeline must emit: + * - `Const:fooService` ── HAS_METHOD ─► `Method:getUser` + * - `Function:caller` ── CALLS ─► `Method:getUser` + * + * The CALLS edge is the canonical proof: `gitnexus_impact` upstream traversal + * is a graph walk over CALLS, so if the edge exists, impact returns the + * caller. Asserting the edge directly avoids wiring an entire `withTestLbugDB` + * fixture for what is effectively a graph-shape assertion. + * + * Test set: + * - Test A: sequential pipeline produces both edges with the right `ownerId` + * - Test B: worker-mode pipeline produces identical edge sets (skipped when + * `dist/parse-worker.js` is missing; CI builds it before running tests) + * - Test C: local-scoped object literal inside a function emits no false- + * positive HAS_METHOD (proves U1 boundary guard is load-bearing) + * - Test D: nested object literal binds neither method to outer (safe + * under-approximation proof) + */ +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'; + +const DIST_WORKER = path.resolve( + __dirname, + '..', + '..', + 'dist', + 'core', + 'ingestion', + 'workers', + 'parse-worker.js', +); +const hasDistWorker = fs.existsSync(DIST_WORKER); + +/** Materialise a tiny fixture repo on disk. Returns the absolute repo root. */ +function writeFixture(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-')); + 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 SERVICE_TS = `export const fooService = { + getUser(id: string) { return id; }, + saveUser(id: string) { return id; }, +}; +`; + +const CONSUMER_TS = `import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} +`; + +// ── Test A: sequential pipeline ────────────────────────────────────────────── + +describe('object-literal owner resolution — sequential pipeline (PR #1718)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/service.ts': SERVICE_TS, + 'src/consumer.ts': CONSUMER_TS, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('emits Const:fooService, Method:getUser, Function:caller exactly once', () => { + expect(getNodesByLabel(result, 'Const').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); + }); + + it('emits exactly the expected HAS_METHOD edges from fooService', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromFoo = hasMethod + .filter((e) => e.source === 'fooService') + .map((e) => e.target) + .sort(); + expect(fromFoo).toEqual(['getUser', 'saveUser']); + }); + + it('the fooService Const node uses the expected graph node ID', () => { + const expectedNodeId = generateId('Const', 'src/service.ts:fooService'); + let fooServiceNode: { id: string; label: string } | undefined; + result.graph.forEachNode((n) => { + if (n.label === 'Const' && n.properties.name === 'fooService') { + fooServiceNode = { id: n.id, label: n.label }; + } + }); + expect(fooServiceNode).toBeDefined(); + expect(fooServiceNode!.id).toBe(expectedNodeId); + }); + + it('emits a CALLS edge from caller to getUser (issue #1358 fix)', () => { + const calls = getRelationships(result, 'CALLS'); + const callerToGetUser = calls.filter((e) => e.source === 'caller' && e.target === 'getUser'); + expect(callerToGetUser.length).toBe(1); + }); +}); + +// ── Test B: worker-mode parity ─────────────────────────────────────────────── + +describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker parity', () => { + let repoRoot: string; + let sequentialResult: PipelineResult; + let workerResult: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/service.ts': SERVICE_TS, + 'src/consumer.ts': CONSUMER_TS, + }); + sequentialResult = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + workerResult = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: false, + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + }); + }, 90000); + + afterAll(() => removeFixture(repoRoot)); + + it('produces the same HAS_METHOD edge set as sequential', () => { + const seqEdges = getRelationships(sequentialResult, 'HAS_METHOD') + .map((e) => `${e.source}->${e.target}`) + .sort(); + const workerEdges = getRelationships(workerResult, 'HAS_METHOD') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(workerEdges).toEqual(seqEdges); + }); + + it('produces the same CALLS edge set as sequential', () => { + const seqEdges = getRelationships(sequentialResult, 'CALLS') + .map((e) => `${e.source}->${e.target}`) + .sort(); + const workerEdges = getRelationships(workerResult, 'CALLS') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(workerEdges).toEqual(seqEdges); + }); +}); + +// ── Test C: negative — local object literal inside a function body ────────── + +describe('object-literal owner resolution — negative (local literal)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/p.ts': `export function processAll() { + const handler = { run(id: string) { return id; } }; + return handler; +} +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('emits no HAS_METHOD edge targeting `run` (no false-positive owner attribution)', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const targetingRun = hasMethod.filter((e) => e.target === 'run'); + expect(targetingRun.length).toBe(0); + }); + + it('the run method node carries no ownerId property', () => { + let runNode: { properties: { name: string; ownerId?: string }; label: string } | undefined; + result.graph.forEachNode((n) => { + if (n.label === 'Method' && n.properties.name === 'run') { + runNode = n as typeof runNode; + } + }); + expect(runNode).toBeDefined(); + expect(runNode!.properties.ownerId).toBe(undefined); + }); +}); + +// ── Test D: negative — nested object literal ───────────────────────────────── + +describe('object-literal owner resolution — negative (nested literal)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/n.ts': `export const s = { + nested: { method(id: string) { return id; } }, + outer(id: string) { return id; }, +}; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('binds the top-level outer method to s but does NOT bind the nested method', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromS = hasMethod + .filter((e) => e.source === 's') + .map((e) => e.target) + .sort(); + expect(fromS).toEqual(['outer']); + }); +});