From 68ec482bf42d3624f0315b6298f584fd23c18f96 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 3 Apr 2026 13:31:14 -0500 Subject: [PATCH] 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) --- src/context/index.ts | 64 ++++++++++++++++++++++++++++++++---------- src/graph/traversal.ts | 4 +-- src/mcp/tools.ts | 7 ++++- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 6f33eb1..596537d 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -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(); + 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(); 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); diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 8bfd4ac..7d6723b 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -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); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index a0e45d1..e7b8ba9 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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;