From 8b541be894bdcbf8cc24ae66f0efc59f5c2553bd Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 3 Apr 2026 12:29:21 -0500 Subject: [PATCH] fix: Improve Python resolution accuracy and context relevance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate cross-language false positives in name resolution and deprioritize test files in context building. Benchmarked on a Python+Rust codebase where 37% of edges were false positives from Python built-in methods resolving to Rust functions (e.g., list.extend → Rust extend). Resolution fixes (index-time): - Filter Python built-in type method calls (list.extend, dict.update, etc.) - Filter bare Python built-in method names (append, extend, pop, keys, etc.) - Add language boundary checks to matchMethodCall strategies 1, 2, and 3 - Penalize cross-language matches: -80 points in findBestMatch (was 0) - Reduce confidence for single cross-language exact matches (0.5 vs 0.9) - Prefer same-language candidates in matchFuzzy Context relevance fixes (query-time): - Add isTestFile() utility detecting test files across Python/JS/TS/Go/Rust/Java - Deprioritize test files in scorePathRelevance (-15 penalty) - Reduce test file scores to 30% in context builder result merging - Both skip deprioritization when query mentions "test" or "spec" Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/index.ts | 12 ++++ src/resolution/index.ts | 42 ++++++++++++ src/resolution/name-matcher.ts | 119 ++++++++++++++++++++++++++++++--- src/search/query-utils.ts | 42 +++++++++++- 4 files changed, 204 insertions(+), 11 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index fb40ef3..6f33eb1 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -25,6 +25,7 @@ import { VectorManager } from '../vectors'; import { formatContextAsMarkdown, formatContextAsJson } from './formatter'; import { logDebug, logWarn } from '../errors'; import { validatePathWithinRoot } from '../utils'; +import { isTestFile } from '../search/query-utils'; /** * Extract likely symbol names from a natural language query @@ -334,6 +335,17 @@ export class ContextBuilder { // Limit total results searchResults = searchResults.slice(0, opts.searchLimit * 2); + // Deprioritize test files unless the query is about tests + const queryLower = query.toLowerCase(); + const isTestQuery = queryLower.includes('test') || queryLower.includes('spec'); + if (!isTestQuery) { + searchResults = searchResults.map(r => ({ + ...r, + score: isTestFile(r.node.filePath) ? r.score * 0.3 : r.score, + })); + searchResults.sort((a, b) => b.score - a.score); + } + // Filter by minimum score let filteredResults = searchResults.filter((r) => r.score >= opts.minScore); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f9752a8..f358e7e 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -375,6 +375,17 @@ export class ReferenceResolver { this.queries.insertEdges(edges); } + // Clean up resolved refs from unresolved_refs table so metrics are accurate + if (result.resolved.length > 0) { + this.queries.deleteSpecificResolvedReferences( + result.resolved.map((r) => ({ + fromNodeId: r.original.fromNodeId, + referenceName: r.original.referenceName, + referenceKind: r.original.referenceKind, + })) + ); + } + return result; } @@ -426,6 +437,37 @@ export class ReferenceResolver { return true; } + // Python built-in method calls (e.g., list.extend, dict.update, self.xxx) + if (ref.language === 'python') { + const dotIdx = name.indexOf('.'); + if (dotIdx > 0) { + const receiver = name.substring(0, dotIdx); + // self.method and cls.method are internal calls, not built-in — let them resolve + // But receiver types that are built-in types should be filtered + const pythonBuiltInTypes = new Set([ + 'list', 'dict', 'set', 'tuple', 'str', 'int', 'float', 'bool', + 'bytes', 'bytearray', 'frozenset', 'object', 'super', + ]); + if (pythonBuiltInTypes.has(receiver)) { + return true; + } + } + // Also filter bare method names that are common Python built-in methods + // These get extracted as unresolved refs when called on arbitrary objects + const pythonBuiltInMethods = new Set([ + 'append', 'extend', 'insert', 'remove', 'pop', 'clear', 'sort', 'reverse', 'copy', + 'update', 'keys', 'values', 'items', 'get', + 'add', 'discard', 'union', 'intersection', 'difference', + 'split', 'join', 'strip', 'lstrip', 'rstrip', 'replace', 'lower', 'upper', + 'startswith', 'endswith', 'find', 'index', 'count', 'encode', 'decode', + 'format', 'isdigit', 'isalpha', 'isalnum', + 'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek', + ]); + if (pythonBuiltInMethods.has(name)) { + return true; + } + } + // Pascal/Delphi built-ins and standard library units if (ref.language === 'pascal') { // Standard RTL/VCL/FMX unit prefixes — these are external dependencies diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0cfccba..7a508b9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -20,12 +20,13 @@ export function matchByExactName( return null; } - // If only one match, use it + // If only one match, use it — but penalize cross-language matches if (candidates.length === 1) { + const isCrossLanguage = candidates[0]!.language !== ref.language; return { original: ref, targetNodeId: candidates[0]!.id, - confidence: 0.9, + confidence: isCrossLanguage ? 0.5 : 0.9, resolvedBy: 'exact-match', }; } @@ -108,12 +109,14 @@ export function matchMethodCall( const [, objectOrClass, methodName] = match; - // Find the class/object first + // Strategy 1: Direct class name match (existing logic) 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 + // Skip cross-language class matches + if (classNode.language !== ref.language) continue; + const nodesInFile = context.getNodesInFile(classNode.filePath); const methodNode = nodesInFile.find( (n) => @@ -133,9 +136,102 @@ export function matchMethodCall( } } + // Strategy 2: Instance variable receiver - try capitalized form to find class + // e.g., "permissionEngine" → look for classes containing "PermissionEngine" + const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1); + if (capitalizedReceiver !== objectOrClass) { + const fuzzyClassCandidates = context.getNodesByName(capitalizedReceiver); + for (const classNode of fuzzyClassCandidates) { + if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { + // Skip cross-language class matches + if (classNode.language !== ref.language) continue; + + 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.8, + resolvedBy: 'instance-method', + }; + } + } + } + } + + // Strategy 3: Find methods by name across the codebase, match by receiver + // name similarity with the containing class. Handles abbreviated variable + // names like permissionEngine → PermissionRuleEngine. + if (methodName) { + const methodCandidates = context.getNodesByName(methodName!); + const methods = methodCandidates.filter( + (n) => n.kind === 'method' && n.name === methodName + ); + + // Filter to same-language candidates first + const sameLanguageMethods = methods.filter(m => m.language === ref.language); + const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods; + + // If only one same-language method with this name exists, use it + if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) { + return { + original: ref, + targetNodeId: targetMethods[0]!.id, + confidence: 0.7, + resolvedBy: 'instance-method', + }; + } + + // Multiple methods: score by receiver name word overlap with class name + if (targetMethods.length > 1) { + const receiverWords = splitCamelCase(objectOrClass!); + let bestMatch: typeof targetMethods[0] | undefined; + let bestScore = 0; + + for (const method of targetMethods) { + const classWords = splitCamelCase(method.qualifiedName); + let score = receiverWords.filter(w => + classWords.some(cw => cw.toLowerCase() === w.toLowerCase()) + ).length; + // Bonus for same language + if (method.language === ref.language) score += 1; + if (score > bestScore) { + bestScore = score; + bestMatch = method; + } + } + + if (bestMatch && bestScore >= 2) { + return { + original: ref, + targetNodeId: bestMatch.id, + confidence: 0.65, + resolvedBy: 'instance-method', + }; + } + } + } + return null; } +/** + * Split a camelCase or PascalCase string into words. + */ +function splitCamelCase(str: string): string[] { + return str.replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[\s._:\/\\]+/) + .filter(w => w.length > 1); +} + /** * Compute directory proximity between two file paths. * Returns a score based on the number of shared directory segments. @@ -187,9 +283,11 @@ function findBestMatch( // Directory proximity bonus — strongly prefer same module/package score += computePathProximity(ref.filePath, candidate.filePath); - // Same language bonus + // Language matching: strongly prefer same language, penalize cross-language if (candidate.language === ref.language) { score += 50; + } else { + score -= 80; } // For call references, prefer functions/methods @@ -235,11 +333,16 @@ export function matchFuzzy( const callableKinds = new Set(['function', 'method', 'class']); const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind)); - if (callableCandidates.length === 1) { + // Prefer same-language matches + const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language); + const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates; + + if (finalCandidates.length === 1) { + const isCrossLanguage = finalCandidates[0]!.language !== ref.language; return { original: ref, - targetNodeId: callableCandidates[0]!.id, - confidence: 0.5, + targetNodeId: finalCandidates[0]!.id, + confidence: isCrossLanguage ? 0.3 : 0.5, resolvedBy: 'fuzzy', }; } diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 0e89f20..91ece3e 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -79,9 +79,45 @@ export function scorePathRelevance(filePath: string, query: string): number { else if (pathLower.includes(term)) score += 3; } + // Deprioritize test files unless the query is explicitly about tests + const queryLower = query.toLowerCase(); + const isTestQuery = queryLower.includes('test') || queryLower.includes('spec'); + if (!isTestQuery && isTestFile(filePath)) { + score -= 15; + } + return score; } +/** + * Check if a file path looks like a test file + */ +export function isTestFile(filePath: string): boolean { + const lower = filePath.toLowerCase(); + const fileName = path.basename(lower); + + // Common test file patterns + return ( + fileName.startsWith('test_') || + fileName.startsWith('test.') || + fileName.endsWith('.test.ts') || + fileName.endsWith('.test.js') || + fileName.endsWith('.test.tsx') || + fileName.endsWith('.test.jsx') || + fileName.endsWith('.spec.ts') || + fileName.endsWith('.spec.js') || + fileName.endsWith('_test.go') || + fileName.endsWith('_test.py') || + fileName.endsWith('_test.rs') || + fileName.endsWith('Tests.java') || + fileName.endsWith('Test.java') || + lower.includes('/tests/') || + lower.includes('/test/') || + lower.includes('/__tests__/') || + lower.includes('/spec/') + ); +} + /** * Kind-based bonus for search ranking * Functions and classes are typically more relevant than variables/imports @@ -91,10 +127,10 @@ export function kindBonus(kind: Node['kind']): number { function: 10, method: 10, class: 8, - interface: 7, + interface: 9, type_alias: 6, struct: 6, - trait: 6, + trait: 9, enum: 5, component: 8, route: 9, @@ -108,7 +144,7 @@ export function kindBonus(kind: Node['kind']): number { parameter: 0, namespace: 4, file: 0, - protocol: 6, + protocol: 9, enum_member: 3, }; return bonuses[kind] ?? 0;