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>
278 lines
7.0 KiB
TypeScript
278 lines
7.0 KiB
TypeScript
/**
|
|
* Name Matcher
|
|
*
|
|
* Handles symbol name matching for reference resolution.
|
|
*/
|
|
|
|
import { Node } from '../types';
|
|
import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
|
|
|
|
/**
|
|
* Try to resolve a reference by exact name match
|
|
*/
|
|
export function matchByExactName(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
const candidates = context.getNodesByName(ref.referenceName);
|
|
|
|
if (candidates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// If only one match, use it
|
|
if (candidates.length === 1) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: candidates[0]!.id,
|
|
confidence: 0.9,
|
|
resolvedBy: 'exact-match',
|
|
};
|
|
}
|
|
|
|
// Multiple matches - try to narrow down
|
|
const bestMatch = findBestMatch(ref, candidates, context);
|
|
if (bestMatch) {
|
|
// Lower confidence when the match is from a distant/unrelated module
|
|
const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
|
|
const confidence = proximity >= 30 ? 0.7 : 0.4;
|
|
return {
|
|
original: ref,
|
|
targetNodeId: bestMatch.id,
|
|
confidence,
|
|
resolvedBy: 'exact-match',
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Try to resolve by qualified name
|
|
*/
|
|
export function matchByQualifiedName(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
// Check if the reference name looks qualified (contains :: or .)
|
|
if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.')) {
|
|
return null;
|
|
}
|
|
|
|
const candidates = context.getNodesByQualifiedName(ref.referenceName);
|
|
|
|
if (candidates.length === 1) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: candidates[0]!.id,
|
|
confidence: 0.95,
|
|
resolvedBy: 'qualified-name',
|
|
};
|
|
}
|
|
|
|
// Try partial qualified name match
|
|
const parts = ref.referenceName.split(/[:.]/);
|
|
const lastName = parts[parts.length - 1];
|
|
if (lastName) {
|
|
const partialCandidates = context.getNodesByName(lastName);
|
|
for (const candidate of partialCandidates) {
|
|
if (candidate.qualifiedName.endsWith(ref.referenceName)) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: candidate.id,
|
|
confidence: 0.85,
|
|
resolvedBy: 'qualified-name',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Try to resolve by method name on a class/object
|
|
*/
|
|
export function matchMethodCall(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
// Parse method call patterns like "obj.method" or "Class::method"
|
|
const dotMatch = ref.referenceName.match(/^(\w+)\.(\w+)$/);
|
|
const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
|
|
|
|
const match = dotMatch || colonMatch;
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const [, objectOrClass, methodName] = match;
|
|
|
|
// Find the class/object first
|
|
const classCandidates = context.getNodesByName(objectOrClass!);
|
|
|
|
for (const classNode of classCandidates) {
|
|
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
|
|
// Look for method in the same file
|
|
const nodesInFile = context.getNodesInFile(classNode.filePath);
|
|
const methodNode = nodesInFile.find(
|
|
(n) =>
|
|
n.kind === 'method' &&
|
|
n.name === methodName &&
|
|
n.qualifiedName.includes(classNode.name)
|
|
);
|
|
|
|
if (methodNode) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: methodNode.id,
|
|
confidence: 0.85,
|
|
resolvedBy: 'qualified-name',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Compute directory proximity between two file paths.
|
|
* Returns a score based on the number of shared directory segments.
|
|
* Higher score = closer in directory tree.
|
|
*/
|
|
function computePathProximity(filePath1: string, filePath2: string): number {
|
|
const dir1 = filePath1.split('/').slice(0, -1);
|
|
const dir2 = filePath2.split('/').slice(0, -1);
|
|
|
|
let shared = 0;
|
|
for (let i = 0; i < Math.min(dir1.length, dir2.length); i++) {
|
|
if (dir1[i] === dir2[i]) {
|
|
shared++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Each shared directory segment contributes 15 points, capped at 80
|
|
return Math.min(shared * 15, 80);
|
|
}
|
|
|
|
/**
|
|
* Find the best matching node when there are multiple candidates
|
|
*/
|
|
function findBestMatch(
|
|
ref: UnresolvedRef,
|
|
candidates: Node[],
|
|
_context: ResolutionContext
|
|
): Node | null {
|
|
// Prioritization rules:
|
|
// 1. Same file > different file
|
|
// 2. Directory proximity (same module/package > different module)
|
|
// 3. Same language > different language
|
|
// 4. Functions/methods > classes/types (for call references)
|
|
// 5. Exported > non-exported
|
|
|
|
let bestScore = -1;
|
|
let bestNode: Node | null = null;
|
|
|
|
for (const candidate of candidates) {
|
|
let score = 0;
|
|
|
|
// Same file bonus
|
|
if (candidate.filePath === ref.filePath) {
|
|
score += 100;
|
|
}
|
|
|
|
// Directory proximity bonus — strongly prefer same module/package
|
|
score += computePathProximity(ref.filePath, candidate.filePath);
|
|
|
|
// Same language bonus
|
|
if (candidate.language === ref.language) {
|
|
score += 50;
|
|
}
|
|
|
|
// For call references, prefer functions/methods
|
|
if (ref.referenceKind === 'calls') {
|
|
if (candidate.kind === 'function' || candidate.kind === 'method') {
|
|
score += 25;
|
|
}
|
|
}
|
|
|
|
// Exported bonus
|
|
if (candidate.isExported) {
|
|
score += 10;
|
|
}
|
|
|
|
// Closer line number (within same file)
|
|
if (candidate.filePath === ref.filePath && candidate.startLine) {
|
|
const distance = Math.abs(candidate.startLine - ref.line);
|
|
score += Math.max(0, 20 - distance / 10);
|
|
}
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestNode = candidate;
|
|
}
|
|
}
|
|
|
|
return bestNode;
|
|
}
|
|
|
|
/**
|
|
* Fuzzy match - last resort with lower confidence
|
|
*/
|
|
export function matchFuzzy(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
const lowerName = ref.referenceName.toLowerCase();
|
|
|
|
// Use pre-built lowercase index for O(1) lookup instead of scanning all nodes
|
|
const candidates = context.getNodesByLowerName(lowerName);
|
|
|
|
// Filter to callable kinds only (function, method, class)
|
|
const callableKinds = new Set(['function', 'method', 'class']);
|
|
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind));
|
|
|
|
if (callableCandidates.length === 1) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: callableCandidates[0]!.id,
|
|
confidence: 0.5,
|
|
resolvedBy: 'fuzzy',
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Match all strategies in order of confidence
|
|
*/
|
|
export function matchReference(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
// Try strategies in order of confidence
|
|
let result: ResolvedRef | null;
|
|
|
|
// 1. Qualified name match (highest confidence)
|
|
result = matchByQualifiedName(ref, context);
|
|
if (result) return result;
|
|
|
|
// 2. Method call pattern
|
|
result = matchMethodCall(ref, context);
|
|
if (result) return result;
|
|
|
|
// 3. Exact name match
|
|
result = matchByExactName(ref, context);
|
|
if (result) return result;
|
|
|
|
// 4. Fuzzy match (lowest confidence)
|
|
result = matchFuzzy(ref, context);
|
|
if (result) return result;
|
|
|
|
return null;
|
|
}
|