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;