fix: Prevent false cross-module edges in name-based resolution
Name matching was creating false `calls` edges between unrelated modules in monorepos because `findBestMatch()` had no concept of directory proximity — functions with common names (e.g. `navigate`) in different apps scored identically and resolved to whichever came first. Adds path proximity scoring (shared directory segments) so same-module candidates strongly win over cross-boundary ones, and lowers confidence for distant matches so import-based resolution takes precedence. Closes #67 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
960bed2253
commit
584bd94ecc
@@ -82,6 +82,129 @@ describe('Resolution Module', () => {
|
||||
expect(result?.resolvedBy).toBe('exact-match');
|
||||
});
|
||||
|
||||
it('should prefer same-module candidates over cross-module matches', () => {
|
||||
// Simulates a Python monorepo where multiple apps define navigate()
|
||||
const candidateA: Node = {
|
||||
id: 'func:apps/app_a/src/server.py:navigate:10',
|
||||
kind: 'function',
|
||||
name: 'navigate',
|
||||
qualifiedName: 'apps/app_a/src/server.py::navigate',
|
||||
filePath: 'apps/app_a/src/server.py',
|
||||
language: 'python',
|
||||
startLine: 10,
|
||||
endLine: 20,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const candidateB: Node = {
|
||||
id: 'func:apps/app_b/src/server.py:navigate:15',
|
||||
kind: 'function',
|
||||
name: 'navigate',
|
||||
qualifiedName: 'apps/app_b/src/server.py::navigate',
|
||||
filePath: 'apps/app_b/src/server.py',
|
||||
language: 'python',
|
||||
startLine: 15,
|
||||
endLine: 25,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const context: ResolutionContext = {
|
||||
getNodesInFile: () => [],
|
||||
getNodesByName: (name) => name === 'navigate' ? [candidateA, candidateB] : [],
|
||||
getNodesByQualifiedName: () => [],
|
||||
getNodesByKind: () => [],
|
||||
fileExists: () => true,
|
||||
readFile: () => null,
|
||||
getProjectRoot: () => '/test',
|
||||
getAllFiles: () => [],
|
||||
getNodesByLowerName: () => [],
|
||||
getImportMappings: () => [],
|
||||
};
|
||||
|
||||
// Reference from app_a should resolve to app_a's navigate, not app_b's
|
||||
const ref = {
|
||||
fromNodeId: 'func:apps/app_a/src/handler.py:handler:5',
|
||||
referenceName: 'navigate',
|
||||
referenceKind: 'calls' as const,
|
||||
line: 5,
|
||||
column: 10,
|
||||
filePath: 'apps/app_a/src/handler.py',
|
||||
language: 'python' as const,
|
||||
};
|
||||
|
||||
const result = matchReference(ref, context);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.targetNodeId).toBe('func:apps/app_a/src/server.py:navigate:10');
|
||||
expect(result?.resolvedBy).toBe('exact-match');
|
||||
});
|
||||
|
||||
it('should lower confidence for cross-module exact matches', () => {
|
||||
// Only one candidate but in a completely different module
|
||||
const candidates: Node[] = [
|
||||
{
|
||||
id: 'func:apps/app_b/src/server.py:navigate:10',
|
||||
kind: 'function',
|
||||
name: 'navigate',
|
||||
qualifiedName: 'apps/app_b/src/server.py::navigate',
|
||||
filePath: 'apps/app_b/src/server.py',
|
||||
language: 'python',
|
||||
startLine: 10,
|
||||
endLine: 20,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'func:apps/app_c/src/server.py:navigate:10',
|
||||
kind: 'function',
|
||||
name: 'navigate',
|
||||
qualifiedName: 'apps/app_c/src/server.py::navigate',
|
||||
filePath: 'apps/app_c/src/server.py',
|
||||
language: 'python',
|
||||
startLine: 10,
|
||||
endLine: 20,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const context: ResolutionContext = {
|
||||
getNodesInFile: () => [],
|
||||
getNodesByName: (name) => name === 'navigate' ? candidates : [],
|
||||
getNodesByQualifiedName: () => [],
|
||||
getNodesByKind: () => [],
|
||||
fileExists: () => true,
|
||||
readFile: () => null,
|
||||
getProjectRoot: () => '/test',
|
||||
getAllFiles: () => [],
|
||||
getNodesByLowerName: () => [],
|
||||
getImportMappings: () => [],
|
||||
};
|
||||
|
||||
// Reference from app_a — neither candidate is in the same module
|
||||
const ref = {
|
||||
fromNodeId: 'func:apps/app_a/src/handler.py:handler:5',
|
||||
referenceName: 'navigate',
|
||||
referenceKind: 'calls' as const,
|
||||
line: 5,
|
||||
column: 10,
|
||||
filePath: 'apps/app_a/src/handler.py',
|
||||
language: 'python' as const,
|
||||
};
|
||||
|
||||
const result = matchReference(ref, context);
|
||||
|
||||
// Should still resolve but with low confidence
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBeLessThanOrEqual(0.4);
|
||||
});
|
||||
|
||||
it('should match qualified name references', () => {
|
||||
const mockClassNode: Node = {
|
||||
id: 'class:user.ts:User:5',
|
||||
|
||||
Reference in New Issue
Block a user