From 9a27e6cb503517ba51e33e272bb8cfe4b53c68e8 Mon Sep 17 00:00:00 2001 From: luyua9 Date: Wed, 20 May 2026 11:01:50 +0800 Subject: [PATCH] fix: link object literal methods to exported bindings --- .../src/core/ingestion/parsing-processor.ts | 16 ++++-- .../src/core/ingestion/utils/ast-helpers.ts | 54 +++++++++++++++++++ .../core/ingestion/workers/parse-worker.ts | 16 ++++-- .../test/unit/parsing-worker-fallback.test.ts | 44 +++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 5cf398bee..2e452f12e 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -14,6 +14,7 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, + findObjectLiteralBindingInfo, getLabelFromCaptures, CLASS_CONTAINER_TYPES, type SyntaxNode, @@ -531,6 +532,10 @@ const processParsingSequential = async ( ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; + const objectLiteralOwnerInfo = + !enclosingClassId && nodeLabel === 'Method' && definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path) + : null; // Qualify method/property IDs with enclosing class name to avoid collisions // e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak" @@ -805,15 +810,18 @@ const processParsingSequential = async ( graph.addRelationship(relationship); // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── - if (enclosingClassId) { + const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null; + if (ownerIdForMemberEdge) { const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; graph.addRelationship({ - id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), - sourceId: enclosingClassId, + id: generateId(memberEdgeType, `${ownerIdForMemberEdge}->${nodeId}`), + sourceId: ownerIdForMemberEdge, targetId: nodeId, type: memberEdgeType, confidence: 1.0, - reason: '', + reason: objectLiteralOwnerInfo + ? 'object literal method belongs to exported object binding' + : '', }); } }); diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 351cfdedb..0bf3573ad 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -411,6 +411,60 @@ export const findEnclosingClassInfo = ( return null; }; +/** Object literal binding info for TS/JS shorthand methods. */ +export interface ObjectLiteralBindingInfo { + ownerId: string; + ownerName: string; +} + +/** + * Find the file-scope variable that owns an object literal method definition. + * + * Covers TypeScript/JavaScript shorthand object methods such as: + * + * export const service = { async load() {} }; + * + * tree-sitter represents `load` as a `method_definition` inside an `object`, + * 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`. + */ +export const findObjectLiteralBindingInfo = ( + node: SyntaxNode, + filePath: string, +): ObjectLiteralBindingInfo | null => { + let current: SyntaxNode | null = node; + let sawObjectLiteral = false; + + 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, + }; + } + + // 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)) + ) { + return null; + } + + current = current.parent; + } + + return null; +}; + /** Convenience wrapper: returns just the class ID string (backward compat). */ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => { return findEnclosingClassInfo(node, filePath)?.classId ?? null; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index da681b070..b7b71e5c2 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -50,6 +50,7 @@ import { FUNCTION_NODE_TYPES, getDefinitionNodeFromCaptures, findEnclosingClassInfo, + findObjectLiteralBindingInfo, type EnclosingClassInfo, getLabelFromCaptures, findDescendant, @@ -2059,6 +2060,10 @@ const processFileGroup = ( ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; + const objectLiteralOwnerInfo = + !enclosingClassId && nodeLabel === 'Method' && definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path) + : null; // Qualify method/property IDs with enclosing class name to avoid collisions const qualifiedName = enclosingClassInfo @@ -2346,15 +2351,18 @@ const processFileGroup = ( }); // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── - if (enclosingClassId) { + const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null; + if (ownerIdForMemberEdge) { const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; result.relationships.push({ - id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), - sourceId: enclosingClassId, + id: generateId(memberEdgeType, `${ownerIdForMemberEdge}->${nodeId}`), + sourceId: ownerIdForMemberEdge, targetId: nodeId, type: memberEdgeType, confidence: 1.0, - reason: '', + reason: objectLiteralOwnerInfo + ? 'object literal method belongs to exported object binding' + : '', }); } } diff --git a/gitnexus/test/unit/parsing-worker-fallback.test.ts b/gitnexus/test/unit/parsing-worker-fallback.test.ts index d25fe938b..0a29e0e6d 100644 --- a/gitnexus/test/unit/parsing-worker-fallback.test.ts +++ b/gitnexus/test/unit/parsing-worker-fallback.test.ts @@ -42,3 +42,47 @@ describe('processParsing worker fallback', () => { ).toBe(true); }); }); + +describe('TypeScript object literal method exports', () => { + it('links exported object literal shorthand methods back to the exported object', async () => { + const graph = createKnowledgeGraph(); + + await processParsing( + graph, + [ + { + path: 'src/foo.ts', + content: `export const fooService = { + async getUser(id: string) { + return findUser(id); + }, + saveUser(user: User) { + return persist(user); + }, +}; +`, + }, + ], + createSymbolTable(), + createASTCache(), + createASTCache(), + ); + + const service = graph.nodes.find( + (node) => node.label === 'Const' && node.properties.name === 'fooService', + ); + expect(service, 'exported object literal should be captured as a Const').toBeDefined(); + + const methodNames = new Set( + graph.nodes.filter((node) => node.label === 'Method').map((node) => node.properties.name), + ); + expect(methodNames).toEqual(new Set(['getUser', 'saveUser'])); + + const linkedMethodNames = graph.relationships + .filter((rel) => rel.type === 'HAS_METHOD' && rel.sourceId === service!.id) + .map((rel) => graph.getNode(rel.targetId)?.properties.name) + .sort(); + + expect(linkedMethodNames).toEqual(['getUser', 'saveUser']); + }); +});