Files
codegraph/src/resolution/name-matcher.ts
T
Colby McHenryandClaude Opus 4.6 584bd94ecc 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>
2026-04-01 14:23:13 -05:00

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