fix: Fix callers/callees for Liquid templates and improve context relevance
Three issues discovered testing CodeGraph against a Shopify Liquid theme:
1. Callers/callees only traversed 'calls' edges, missing 'references' and
'imports' edges that Liquid extraction creates for {% render %} and
{% section %} tags. Expanded edge filter in getCallers/getCallees.
2. Context builder only ran text search as a fallback when semantic search
returned nothing. For template-heavy codebases, semantic search returns
irrelevant results (e.g., "Toast" for a header navigation query) while
text/path-based matching would find the right files. Now always runs
text search alongside semantic search with multi-term boosting.
3. MCP findAllSymbols only matched nodes by exact name, missing file nodes
whose basename (without extension) matched the symbol. This caused
callers to find zero results even with correct edges, since references
edges point to file nodes (e.g., "product-card.liquid") not component
nodes (e.g., "product-card").
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
47ed47bf04
commit
68ec482bf4
+49
-15
@@ -23,9 +23,9 @@ import { QueryBuilder } from '../db/queries';
|
||||
import { GraphTraverser } from '../graph';
|
||||
import { VectorManager } from '../vectors';
|
||||
import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
|
||||
import { logDebug, logWarn } from '../errors';
|
||||
import { logDebug } from '../errors';
|
||||
import { validatePathWithinRoot } from '../utils';
|
||||
import { isTestFile } from '../search/query-utils';
|
||||
import { isTestFile, extractSearchTerms } from '../search/query-utils';
|
||||
|
||||
/**
|
||||
* Extract likely symbol names from a natural language query
|
||||
@@ -298,21 +298,47 @@ export class ContextBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Fall back to text search if no semantic results
|
||||
if (semanticResults.length === 0 && exactMatches.length === 0) {
|
||||
try {
|
||||
const textResults = this.queries.searchNodes(query, {
|
||||
limit: opts.searchLimit,
|
||||
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
|
||||
});
|
||||
semanticResults = textResults;
|
||||
} catch (error) {
|
||||
logWarn('Text search failed', { query, error: String(error) });
|
||||
// Return empty results
|
||||
// Step 4: Always run text search for natural language term matching
|
||||
// This catches file-name and node-name matches that semantic search may miss,
|
||||
// which is critical for template-heavy codebases (e.g., Liquid/Shopify themes)
|
||||
// where file names are the primary identifiers.
|
||||
let textResults: SearchResult[] = [];
|
||||
try {
|
||||
const searchTerms = extractSearchTerms(query);
|
||||
if (searchTerms.length > 0) {
|
||||
// Search each term individually to get broader coverage,
|
||||
// then boost results that match multiple terms
|
||||
const termResultsMap = new Map<string, { result: SearchResult; termHits: number }>();
|
||||
for (const term of searchTerms) {
|
||||
const termResults = this.queries.searchNodes(term, {
|
||||
limit: opts.searchLimit * 2,
|
||||
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
|
||||
});
|
||||
for (const r of termResults) {
|
||||
const existing = termResultsMap.get(r.node.id);
|
||||
if (existing) {
|
||||
existing.termHits++;
|
||||
existing.result.score = Math.max(existing.result.score, r.score);
|
||||
} else {
|
||||
termResultsMap.set(r.node.id, { result: r, termHits: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Boost results matching multiple terms and sort
|
||||
textResults = Array.from(termResultsMap.values())
|
||||
.map(({ result, termHits }) => ({
|
||||
...result,
|
||||
score: result.score + (termHits - 1) * 5,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, opts.searchLimit * 2);
|
||||
}
|
||||
logDebug('Text search results', { count: textResults.length });
|
||||
} catch (error) {
|
||||
logDebug('Text search failed', { query, error: String(error) });
|
||||
}
|
||||
|
||||
// Step 5: Merge results, prioritizing exact matches
|
||||
// Step 5: Merge results, prioritizing exact matches, then text (path-boosted), then semantic
|
||||
const seenIds = new Set<string>();
|
||||
let searchResults: SearchResult[] = [];
|
||||
|
||||
@@ -324,7 +350,15 @@ export class ContextBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Add semantic/text results
|
||||
// Add text search results (includes path relevance scoring from searchNodes)
|
||||
for (const result of textResults) {
|
||||
if (!seenIds.has(result.node.id)) {
|
||||
seenIds.add(result.node.id);
|
||||
searchResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Add semantic results
|
||||
for (const result of semanticResults) {
|
||||
if (!seenIds.has(result.node.id)) {
|
||||
seenIds.add(result.node.id);
|
||||
|
||||
@@ -248,7 +248,7 @@ export class GraphTraverser {
|
||||
}
|
||||
visited.add(nodeId);
|
||||
|
||||
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls']);
|
||||
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports']);
|
||||
|
||||
for (const edge of incomingEdges) {
|
||||
const callerNode = this.queries.getNodeById(edge.source);
|
||||
@@ -287,7 +287,7 @@ export class GraphTraverser {
|
||||
}
|
||||
visited.add(nodeId);
|
||||
|
||||
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls']);
|
||||
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports']);
|
||||
|
||||
for (const edge of outgoingEdges) {
|
||||
const calleeNode = this.queries.getNodeById(edge.target);
|
||||
|
||||
+6
-1
@@ -874,7 +874,12 @@ export class ToolHandler {
|
||||
return { nodes: [], note: '' };
|
||||
}
|
||||
|
||||
const exactMatches = results.filter(r => r.node.name === symbol);
|
||||
// Match by exact name, OR by file basename without extension
|
||||
// (e.g., "product-card" matches file node named "product-card.liquid")
|
||||
const exactMatches = results.filter(r =>
|
||||
r.node.name === symbol ||
|
||||
(r.node.kind === 'file' && r.node.name.replace(/\.[^.]+$/, '') === symbol)
|
||||
);
|
||||
|
||||
if (exactMatches.length <= 1) {
|
||||
const node = exactMatches[0]?.node ?? results[0]!.node;
|
||||
|
||||
Reference in New Issue
Block a user