Quellcode durchsuchen

Enhance search result merging and Svelte component extraction

Changes search result deduplication to use max scores across channels instead of first-seen prioritization, adds template component usage extraction for Svelte files, exempts exact matches from single-term score dampening, prioritizes structural edges in graph traversal, and increases explore tool node budget while including edge source locations in file clustering.
Colby McHenry vor 2 Monaten
Ursprung
Commit
19532a81a5
4 geänderte Dateien mit 111 neuen und 16 gelöschten Zeilen
  1. 25 10
      src/context/index.ts
  2. 47 0
      src/extraction/svelte-extractor.ts
  3. 7 1
      src/graph/traversal.ts
  4. 32 5
      src/mcp/tools.ts

+ 25 - 10
src/context/index.ts

@@ -437,22 +437,30 @@ export class ContextBuilder {
       logDebug('Text search failed', { query, error: String(error) });
     }
 
-    // Step 4: Merge results, prioritizing exact matches, then text (path-boosted)
-    const seenIds = new Set<string>();
+    // Step 4: Merge results, taking the max score when duplicates appear
+    // across search channels. Exact matches may have lower scores than FTS
+    // results for the same node — use the best score from any channel.
+    const resultById = new Map<string, SearchResult>();
     let searchResults: SearchResult[] = [];
 
-    // Add exact matches first (highest priority)
+    // Add exact matches first
     for (const result of exactMatches) {
-      if (!seenIds.has(result.node.id)) {
-        seenIds.add(result.node.id);
+      const existing = resultById.get(result.node.id);
+      if (existing) {
+        existing.score = Math.max(existing.score, result.score);
+      } else {
+        resultById.set(result.node.id, result);
         searchResults.push(result);
       }
     }
 
-    // Add text search results (includes path relevance scoring from searchNodes)
+    // Add text search results, upgrading scores for duplicates
     for (const result of textResults) {
-      if (!seenIds.has(result.node.id)) {
-        seenIds.add(result.node.id);
+      const existing = resultById.get(result.node.id);
+      if (existing) {
+        existing.score = Math.max(existing.score, result.score);
+      } else {
+        resultById.set(result.node.id, result);
         searchResults.push(result);
       }
     }
@@ -498,6 +506,12 @@ export class ContextBuilder {
         termGroups.push(group);
       }
 
+      // Build a set of exact-match node IDs so we can exempt them from dampening.
+      // When the query is "LiveEditMode DevServerPreview", these are specific
+      // symbols the user asked for — dampening them because they only match 1
+      // term group is counter-productive.
+      const exactMatchIds = new Set(exactMatches.map(r => r.node.id));
+
       for (const result of searchResults) {
         // Check term matches in name (substring) and path DIRECTORIES (exact).
         // Directory segments must match exactly — "search" matches directory
@@ -517,9 +531,10 @@ export class ContextBuilder {
         if (matchCount >= 2) {
           // Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x
           result.score *= 1 + matchCount * 0.5;
-        } else {
+        } else if (!exactMatchIds.has(result.node.id)) {
           // Mild dampen for single-term matches — they might be generic
-          // but could also be the right result (e.g., "Protocol" class for an IPC query)
+          // but could also be the right result (e.g., "Protocol" class for an IPC query).
+          // Exempt exact name matches: they are specific symbols the user queried for.
           result.score *= 0.6;
         }
       }

+ 47 - 0
src/extraction/svelte-extractor.ts

@@ -54,6 +54,9 @@ export class SvelteExtractor {
       // Extract function calls from template expressions ({fn(...)})
       this.extractTemplateCalls(componentNode.id, scriptBlocks);
 
+      // Extract component usages from template (<ComponentName>)
+      this.extractTemplateComponents(componentNode.id);
+
       // Filter out Svelte rune calls ($state, $props, $derived, etc.)
       this.unresolvedReferences = this.unresolvedReferences.filter(
         ref => !SVELTE_RUNES.has(ref.referenceName)
@@ -273,4 +276,48 @@ export class SvelteExtractor {
       }
     }
   }
+
+  /**
+   * Extract component usages from the Svelte template.
+   *
+   * PascalCase tags like <Modal>, <Button />, <DevServerPreview> represent
+   * component instantiations — analogous to function calls in imperative code.
+   * Capturing these creates graph edges from parent to child components and
+   * gives codegraph_explore anchor points in the template markup.
+   */
+  private extractTemplateComponents(componentNodeId: string): void {
+    // Build ranges covered by <script> and <style> blocks to skip them
+    const coveredRanges: Array<[number, number]> = [];
+    const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
+    let tagMatch;
+    while ((tagMatch = tagRegex.exec(this.source)) !== null) {
+      const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
+      const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
+      coveredRanges.push([startLine, endLine]);
+    }
+
+    const lines = this.source.split('\n');
+    // Match PascalCase opening/self-closing tags (closing tags </Foo> start with </ so won't match)
+    const componentTagRegex = /<([A-Z][a-zA-Z0-9_$]*)\b/g;
+
+    for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
+      if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
+
+      const line = lines[lineIdx]!;
+      let match;
+      while ((match = componentTagRegex.exec(line)) !== null) {
+        const componentName = match[1]!;
+
+        this.unresolvedReferences.push({
+          fromNodeId: componentNodeId,
+          referenceName: componentName,
+          referenceKind: 'references',
+          line: lineIdx + 1, // 1-indexed
+          column: match.index + 1,
+          filePath: this.filePath,
+          language: 'svelte',
+        });
+      }
+    }
+  }
 }

+ 7 - 1
src/graph/traversal.ts

@@ -81,8 +81,14 @@ export class GraphTraverser {
         continue;
       }
 
-      // Get adjacent edges
+      // Get adjacent edges, prioritizing structural edges (contains, calls)
+      // over reference edges so BFS discovers internal structure before
+      // fanning out to external references (e.g., component usages in templates).
       const adjacentEdges = this.getAdjacentEdges(node.id, opts.direction, opts.edgeKinds);
+      adjacentEdges.sort((a, b) => {
+        const priority = (e: Edge) => e.kind === 'contains' ? 0 : e.kind === 'calls' ? 1 : 2;
+        return priority(a) - priority(b);
+      });
 
       for (const adjEdge of adjacentEdges) {
         // Determine next node: for 'both' direction, edges can be either

+ 32 - 5
src/mcp/tools.ts

@@ -658,11 +658,14 @@ export class ToolHandler {
     const maxFiles = clamp((args.maxFiles as number) || 12, 1, 20);
     const projectRoot = cg.getProjectRoot();
 
-    // Step 1: Find relevant context with generous parameters
+    // Step 1: Find relevant context with generous parameters.
+    // Use a large maxNodes budget — explore has its own 35k char output limit
+    // that prevents context bloat, so more nodes just means better coverage
+    // across entry points (especially for large files like Svelte components).
     const subgraph = await cg.findRelevantContext(query, {
       searchLimit: 8,
       traversalDepth: 3,
-      maxNodes: 80,
+      maxNodes: 200,
       minScore: 0.2,
     });
 
@@ -802,10 +805,34 @@ export class ToolHandler {
 
       // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
       // Sort by start line, then merge overlapping/adjacent ranges (within 15 lines).
-      const ranges = group.nodes
+      // Include both node ranges AND edge source locations so template sections
+      // with component usages/calls are covered (not just script block symbols).
+      const ranges: Array<{ start: number; end: number; name: string; kind: string }> = group.nodes
         .filter(n => n.startLine > 0 && n.endLine > 0)
-        .map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind }))
-        .sort((a, b) => a.start - b.start);
+        // Skip file/component nodes that span the entire file — they'd create one giant cluster
+        .filter(n => !(n.kind === 'component' && n.startLine === 1 && n.endLine >= fileLines.length - 1))
+        .map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind }));
+
+      // Add edge source locations in this file — captures template references
+      // (component usages, event handlers) that aren't nodes themselves.
+      // Query edges directly from the DB (not just the subgraph) because BFS
+      // traversal may have pruned template reference targets due to node budget.
+      const edgeLines = new Set<string>(); // dedup by "line:name"
+      for (const node of group.nodes) {
+        const outgoing = cg.getOutgoingEdges(node.id);
+        for (const edge of outgoing) {
+          if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
+          const key = `${edge.line}:${edge.target}`;
+          if (edgeLines.has(key)) continue;
+          edgeLines.add(key);
+          // Look up target name from subgraph first, fall back to edge kind
+          const targetNode = subgraph.nodes.get(edge.target);
+          const targetName = targetNode?.name ?? edge.kind;
+          ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind });
+        }
+      }
+
+      ranges.sort((a, b) => a.start - b.start);
 
       if (ranges.length === 0) continue;