Add evaluation framework and fix call graph extraction

- Add evaluation test suite with TypeScript and Python fixtures
- Fix MCP server to defer CodeGraph init until rootUri received
- Fix call edge extraction by calling resolveReferences() after indexAll/sync
- Fix glob matching for root-level files (e.g., **/*.py now matches auth.py)
- Fix duplicate node extraction for methods inside classes
- Update context tests to use buildContext for semantic search + graph traversal
- Export unused formatter functions to fix build

Evaluation results:
- TypeScript: 96% precision, 79% recall, 85% F1
- Python: 99% precision, 80% recall, 85% F1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-01-18 18:48:22 -06:00
co-authored by Claude Opus 4.5
parent e306114607
commit 6b672f9152
30 changed files with 2600 additions and 129 deletions
+102 -3
View File
@@ -390,11 +390,46 @@ export class QueryBuilder {
}
/**
* Search nodes by name using FTS
* Search nodes by name using FTS with fallback to LIKE for better matching
*
* Search strategy:
* 1. Try FTS5 prefix match (query*) for word-start matching
* 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle")
* 3. Score results based on match quality
*/
searchNodes(query: string, options: SearchOptions = {}): SearchResult[] {
const { kinds, languages, limit = 100, offset = 0 } = options;
// First try FTS5 with prefix matching
let results = this.searchNodesFTS(query, { kinds, languages, limit, offset });
// If no FTS results, try LIKE-based substring search
if (results.length === 0 && query.length >= 2) {
results = this.searchNodesLike(query, { kinds, languages, limit, offset });
}
return results;
}
/**
* FTS5 search with prefix matching
*/
private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] {
const { kinds, languages, limit = 100, offset = 0 } = options;
// Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate")
// Escape special FTS5 characters and add prefix wildcard
const ftsQuery = query
.replace(/['"*()]/g, '') // Remove special chars
.split(/\s+/)
.filter(term => term.length > 0)
.map(term => `"${term}"*`) // Prefix match each term
.join(' OR ');
if (!ftsQuery) {
return [];
}
let sql = `
SELECT nodes.*, bm25(nodes_fts) as score
FROM nodes_fts
@@ -402,7 +437,7 @@ export class QueryBuilder {
WHERE nodes_fts MATCH ?
`;
const params: (string | number)[] = [query];
const params: (string | number)[] = [ftsQuery];
if (kinds && kinds.length > 0) {
sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`;
@@ -417,11 +452,75 @@ export class QueryBuilder {
sql += ' ORDER BY score LIMIT ? OFFSET ?';
params.push(limit, offset);
try {
const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
return rows.map((row) => ({
node: rowToNode(row),
score: Math.abs(row.score), // bm25 returns negative scores
}));
} catch {
// FTS query failed, return empty
return [];
}
}
/**
* LIKE-based substring search for cases where FTS doesn't match
* Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle")
*/
private searchNodesLike(query: string, options: SearchOptions): SearchResult[] {
const { kinds, languages, limit = 100, offset = 0 } = options;
let sql = `
SELECT nodes.*,
CASE
WHEN name = ? THEN 1.0
WHEN name LIKE ? THEN 0.9
WHEN name LIKE ? THEN 0.8
WHEN qualified_name LIKE ? THEN 0.7
ELSE 0.5
END as score
FROM nodes
WHERE (
name LIKE ? OR
qualified_name LIKE ? OR
name LIKE ?
)
`;
// Pattern variants for better matching
const exactMatch = query;
const startsWith = `${query}%`;
const contains = `%${query}%`;
const params: (string | number)[] = [
exactMatch, // Exact match score
startsWith, // Starts with score
contains, // Contains score
contains, // Qualified name score
contains, // WHERE: name contains
contains, // WHERE: qualified_name contains
startsWith, // WHERE: name starts with
];
if (kinds && kinds.length > 0) {
sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
params.push(...kinds);
}
if (languages && languages.length > 0) {
sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
params.push(...languages);
}
sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
return rows.map((row) => ({
node: rowToNode(row),
score: Math.abs(row.score), // bm25 returns negative scores
score: row.score,
}));
}