Просмотр исходного кода

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>
Colby McHenry 2 месяцев назад
Родитель
Сommit
68ec482bf4
3 измененных файлов с 57 добавлено и 18 удалено
  1. 49 15
      src/context/index.ts
  2. 2 2
      src/graph/traversal.ts
  3. 6 1
      src/mcp/tools.ts

+ 49 - 15
src/context/index.ts

@@ -23,9 +23,9 @@ import { QueryBuilder } from '../db/queries';
 import { GraphTraverser } from '../graph';
 import { GraphTraverser } from '../graph';
 import { VectorManager } from '../vectors';
 import { VectorManager } from '../vectors';
 import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
 import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
-import { logDebug, logWarn } from '../errors';
+import { logDebug } from '../errors';
 import { validatePathWithinRoot } from '../utils';
 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
  * 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>();
     const seenIds = new Set<string>();
     let searchResults: SearchResult[] = [];
     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) {
     for (const result of semanticResults) {
       if (!seenIds.has(result.node.id)) {
       if (!seenIds.has(result.node.id)) {
         seenIds.add(result.node.id);
         seenIds.add(result.node.id);

+ 2 - 2
src/graph/traversal.ts

@@ -248,7 +248,7 @@ export class GraphTraverser {
     }
     }
     visited.add(nodeId);
     visited.add(nodeId);
 
 
-    const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls']);
+    const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports']);
 
 
     for (const edge of incomingEdges) {
     for (const edge of incomingEdges) {
       const callerNode = this.queries.getNodeById(edge.source);
       const callerNode = this.queries.getNodeById(edge.source);
@@ -287,7 +287,7 @@ export class GraphTraverser {
     }
     }
     visited.add(nodeId);
     visited.add(nodeId);
 
 
-    const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls']);
+    const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports']);
 
 
     for (const edge of outgoingEdges) {
     for (const edge of outgoingEdges) {
       const calleeNode = this.queries.getNodeById(edge.target);
       const calleeNode = this.queries.getNodeById(edge.target);

+ 6 - 1
src/mcp/tools.ts

@@ -874,7 +874,12 @@ export class ToolHandler {
       return { nodes: [], note: '' };
       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) {
     if (exactMatches.length <= 1) {
       const node = exactMatches[0]?.node ?? results[0]!.node;
       const node = exactMatches[0]?.node ?? results[0]!.node;