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.
This commit is contained in:
+25
-10
@@ -437,22 +437,30 @@ export class ContextBuilder {
|
|||||||
logDebug('Text search failed', { query, error: String(error) });
|
logDebug('Text search failed', { query, error: String(error) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Merge results, prioritizing exact matches, then text (path-boosted)
|
// Step 4: Merge results, taking the max score when duplicates appear
|
||||||
const seenIds = new Set<string>();
|
// 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[] = [];
|
let searchResults: SearchResult[] = [];
|
||||||
|
|
||||||
// Add exact matches first (highest priority)
|
// Add exact matches first
|
||||||
for (const result of exactMatches) {
|
for (const result of exactMatches) {
|
||||||
if (!seenIds.has(result.node.id)) {
|
const existing = resultById.get(result.node.id);
|
||||||
seenIds.add(result.node.id);
|
if (existing) {
|
||||||
|
existing.score = Math.max(existing.score, result.score);
|
||||||
|
} else {
|
||||||
|
resultById.set(result.node.id, result);
|
||||||
searchResults.push(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) {
|
for (const result of textResults) {
|
||||||
if (!seenIds.has(result.node.id)) {
|
const existing = resultById.get(result.node.id);
|
||||||
seenIds.add(result.node.id);
|
if (existing) {
|
||||||
|
existing.score = Math.max(existing.score, result.score);
|
||||||
|
} else {
|
||||||
|
resultById.set(result.node.id, result);
|
||||||
searchResults.push(result);
|
searchResults.push(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,6 +506,12 @@ export class ContextBuilder {
|
|||||||
termGroups.push(group);
|
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) {
|
for (const result of searchResults) {
|
||||||
// Check term matches in name (substring) and path DIRECTORIES (exact).
|
// Check term matches in name (substring) and path DIRECTORIES (exact).
|
||||||
// Directory segments must match exactly — "search" matches directory
|
// Directory segments must match exactly — "search" matches directory
|
||||||
@@ -517,9 +531,10 @@ export class ContextBuilder {
|
|||||||
if (matchCount >= 2) {
|
if (matchCount >= 2) {
|
||||||
// Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x
|
// Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x
|
||||||
result.score *= 1 + matchCount * 0.5;
|
result.score *= 1 + matchCount * 0.5;
|
||||||
} else {
|
} else if (!exactMatchIds.has(result.node.id)) {
|
||||||
// Mild dampen for single-term matches — they might be generic
|
// 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;
|
result.score *= 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export class SvelteExtractor {
|
|||||||
// Extract function calls from template expressions ({fn(...)})
|
// Extract function calls from template expressions ({fn(...)})
|
||||||
this.extractTemplateCalls(componentNode.id, scriptBlocks);
|
this.extractTemplateCalls(componentNode.id, scriptBlocks);
|
||||||
|
|
||||||
|
// Extract component usages from template (<ComponentName>)
|
||||||
|
this.extractTemplateComponents(componentNode.id);
|
||||||
|
|
||||||
// Filter out Svelte rune calls ($state, $props, $derived, etc.)
|
// Filter out Svelte rune calls ($state, $props, $derived, etc.)
|
||||||
this.unresolvedReferences = this.unresolvedReferences.filter(
|
this.unresolvedReferences = this.unresolvedReferences.filter(
|
||||||
ref => !SVELTE_RUNES.has(ref.referenceName)
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,14 @@ export class GraphTraverser {
|
|||||||
continue;
|
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);
|
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) {
|
for (const adjEdge of adjacentEdges) {
|
||||||
// Determine next node: for 'both' direction, edges can be either
|
// Determine next node: for 'both' direction, edges can be either
|
||||||
|
|||||||
+32
-5
@@ -658,11 +658,14 @@ export class ToolHandler {
|
|||||||
const maxFiles = clamp((args.maxFiles as number) || 12, 1, 20);
|
const maxFiles = clamp((args.maxFiles as number) || 12, 1, 20);
|
||||||
const projectRoot = cg.getProjectRoot();
|
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, {
|
const subgraph = await cg.findRelevantContext(query, {
|
||||||
searchLimit: 8,
|
searchLimit: 8,
|
||||||
traversalDepth: 3,
|
traversalDepth: 3,
|
||||||
maxNodes: 80,
|
maxNodes: 200,
|
||||||
minScore: 0.2,
|
minScore: 0.2,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -802,10 +805,34 @@ export class ToolHandler {
|
|||||||
|
|
||||||
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
|
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
|
||||||
// Sort by start line, then merge overlapping/adjacent ranges (within 15 lines).
|
// 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)
|
.filter(n => n.startLine > 0 && n.endLine > 0)
|
||||||
.map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind }))
|
// Skip file/component nodes that span the entire file — they'd create one giant cluster
|
||||||
.sort((a, b) => a.start - b.start);
|
.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;
|
if (ranges.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user