fix: link object literal methods to exported bindings

This commit is contained in:
luyua9 2026-05-20 11:01:50 +08:00
parent 803f0bed5f
commit 9a27e6cb50
4 changed files with 122 additions and 8 deletions

View file

@ -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'
: '',
});
}
});

View file

@ -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;

View file

@ -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'
: '',
});
}
}

View file

@ -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']);
});
});