/** * Context Builder * * Builds rich context for tasks by combining FTS search with graph traversal. * Outputs structured context ready to inject into Claude. */ import * as fs from 'fs'; import * as path from 'path'; import { Node, Edge, NodeKind, EdgeKind, Subgraph, CodeBlock, TaskContext, TaskInput, BuildContextOptions, FindRelevantContextOptions, SearchResult, } from '../types'; import { QueryBuilder } from '../db/queries'; import { GraphTraverser } from '../graph'; import { formatContextAsMarkdown, formatContextAsJson } from './formatter'; import { logDebug } from '../errors'; import { validatePathWithinRoot, isConfigLeafNode } from '../utils'; import { isTestFile, extractSearchTerms, scorePathRelevance, getStemVariants, isDistinctiveIdentifier, blankNonAsciiWords, splitAtCjkSwitches, meetsQueryWordFloor, } from '../search/query-utils'; import { LOW_CONFIDENCE_MARKER } from './markers'; import { findNamedCopybooks, isCopybookInclude } from '../graph/cobol-copybooks'; import { describeSynthesizedHop } from '../graph/synthesized-hop'; /** * Most top-level declarations of one copybook that become entry points — its * record layouts, or the first paragraphs of a procedure copybook. The rest of * the copybook is one hop away (explore pins the whole file). */ const MAX_COPYBOOK_DECLARATION_ENTRIES = 3; /** * The most symbol names, and search terms, of one query that * `findRelevantContext` searches the index for. A question or a bag of symbol * names stays well below both: a name like `RefreshTokenService` is one * symbol but several terms (`refreshtokenservice`, `refresh`, `token`, * `service`, stems). A pasted log, document or source file runs to hundreds * of words, and every word costs several searches of the index, so such a * query took minutes on a large project and the prompt hook gave up on it * (#2620). Both lists put code-shaped words (camelCase, snake_case, dotted * names) first and keep the order they appear in, so a long query is searched * by its identifiers and its opening words. */ export const MAX_QUERY_SYMBOLS = 64; export const MAX_QUERY_TERMS = 128; /** * Extract likely symbol names from a natural language query * * Identifies potential code symbols using patterns: * - CamelCase: UserService, signInWithGoogle * - snake_case: user_service, sign_in * - SCREAMING_SNAKE: MAX_RETRIES * - dot.notation: app.isPackaged (extracts both sides) * - Single words that look like identifiers (no spaces, not common English words) * - Names in other scripts, whole: 로그인, 寻路服务, état (#516, #1262) * * @param query - Natural language query * @returns Array of potential symbol names */ export function extractSymbolsFromQuery(query: string): string[] { const symbols = new Set(); // The ASCII patterns below would read `état` as `tat`; they see such words // blanked out, and the Unicode pass at the end takes them whole. const asciiView = blankNonAsciiWords(query); // Extract CamelCase identifiers (2+ chars, starts with letter) const camelCasePattern = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)*|[a-z]+(?:[A-Z][a-z]*)+)\b/g; let match; while ((match = camelCasePattern.exec(asciiView)) !== null) { if (match[1] && match[1].length >= 2) { symbols.add(match[1]); } } // Extract snake_case identifiers const snakeCasePattern = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/gi; while ((match = snakeCasePattern.exec(asciiView)) !== null) { if (match[1] && match[1].length >= 3) { symbols.add(match[1]); } } // Extract SCREAMING_SNAKE_CASE const screamingPattern = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g; while ((match = screamingPattern.exec(asciiView)) !== null) { if (match[1]) { symbols.add(match[1]); } } // Extract ALL_CAPS acronyms (2+ chars, e.g., REST, HTTP, LRU, API) const acronymPattern = /\b([A-Z]{2,})\b/g; while ((match = acronymPattern.exec(asciiView)) !== null) { if (match[1]) { symbols.add(match[1]); } } // Extract dot.notation and split into parts (e.g., "app.isPackaged" -> ["app", "isPackaged"]) const dotPattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+)\b/g; while ((match = dotPattern.exec(asciiView)) !== null) { if (match[1]) { // Add both the full path and individual parts symbols.add(match[1]); const parts = match[1].split('.'); for (const part of parts) { if (part.length >= 2) { symbols.add(part); } } } } // Extract plain lowercase identifiers (3+ chars, not already matched) // Catches symbol names like "undo", "redo", "history", "render", "parse" const lowercasePattern = /\b([a-z][a-z0-9]{2,})\b/g; while ((match = lowercasePattern.exec(asciiView)) !== null) { if (match[1]) { symbols.add(match[1]); } } // Names with a letter outside ASCII, whole runs only (#2005 keeps partial // Chinese words out of search): `로그인`, `寻路服务`, `état`. A name that // switches between Han, kana or Hangul and another script, or joins words // with `_`, is tried as written (`get用户信息`) and by its words, so // `NavMeshAgent类型` also names `类型`; its ASCII side is the patterns' above. for (const run of query.match(/[\p{L}\p{N}\p{M}_$]+/gu) ?? []) { if (!/[^\x00-\x7F]/.test(run)) continue; const words = run.split(/[_$]+/).flatMap(splitAtCjkSwitches); if (words.length > 1 && meetsQueryWordFloor(run)) symbols.add(run); for (const word of words) { if (/[^\x00-\x7F]/.test(word) && meetsQueryWordFloor(word)) symbols.add(word); } } // Filter out common English words that aren't likely symbol names const commonWords = new Set([ 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'have', 'been', 'will', 'would', 'could', 'should', 'does', 'done', 'make', 'made', 'use', 'used', 'using', 'work', 'works', 'find', 'found', 'show', 'call', 'called', 'calling', 'get', 'set', 'add', 'all', 'any', 'how', 'what', 'when', 'where', 'which', 'who', 'why', 'not', 'but', 'are', 'was', 'were', 'has', 'had', 'its', 'can', 'did', 'may', 'also', 'into', 'than', 'then', 'them', 'each', 'other', 'some', 'such', 'only', 'same', 'about', 'after', 'before', 'between', 'through', 'during', 'without', 'again', 'further', 'once', 'here', 'there', 'both', 'just', 'more', 'most', 'very', 'being', 'having', 'doing', 'system', 'need', 'needs', 'want', 'wants', 'like', 'look', 'change', 'changes', 'changed', 'changing', // Common English nouns/verbs that match thousands of unrelated code symbols 'layer', 'handle', 'handles', 'handling', 'incoming', 'outgoing', 'data', 'flow', 'flows', 'level', 'levels', 'request', 'requests', 'response', 'responses', 'implement', 'implements', 'implementation', 'interface', 'interfaces', 'class', 'classes', 'method', 'methods', 'trigger', 'triggers', 'affected', 'affect', 'affects', 'else', 'code', 'failing', 'failed', 'silently', 'decide', 'decides', 'return', 'returns', 'returned', 'take', 'takes', 'taken', 'check', 'checks', 'checked', 'create', 'creates', 'created', 'read', 'reads', 'write', 'writes', 'written', 'start', 'starts', 'stop', 'stops', 'run', 'runs', 'running', ]); return Array.from(symbols).filter(s => !commonWords.has(s.toLowerCase())); } /** * Default options for context building * * Tuned for minimal context usage while still providing useful results: * - Fewer nodes and code blocks by default * - Smaller code block size limit * - Shallower traversal */ const DEFAULT_BUILD_OPTIONS: Required = { maxNodes: 20, // Reduced from 50 - most tasks don't need 50 symbols maxCodeBlocks: 5, // Reduced from 10 - only show most relevant code maxCodeBlockSize: 1500, // Reduced from 2000 includeCode: true, format: 'markdown', searchLimit: 3, // Reduced from 5 - fewer entry points traversalDepth: 1, // Reduced from 2 - shallower graph expansion minScore: 0.3, }; /** * Node kinds that provide high information value in context results. * Imports/exports are excluded because they have near-zero information density - * they tell you something exists, not how it works. */ const HIGH_VALUE_NODE_KINDS: NodeKind[] = [ 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'union', 'trait', 'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace', ]; /** * Default options for finding relevant context */ const DEFAULT_FIND_OPTIONS: Required = { searchLimit: 3, // Reduced from 5 traversalDepth: 1, // Reduced from 2 maxNodes: 20, // Reduced from 50 minScore: 0.3, edgeKinds: [], nodeKinds: HIGH_VALUE_NODE_KINDS, // Filter out imports/exports by default seedNames: [], // Segment-vocab supplement — filled by the facade }; // Re-export the low-confidence sentinel (defined in a dependency-free leaf so // the MCP layer can import it without pulling this module's deps onto the // cold-start path). Builder code below uses the imported binding directly. export { LOW_CONFIDENCE_MARKER } from './markers'; /** * Context Builder * * Coordinates semantic search and graph traversal to build * comprehensive context for tasks. */ export class ContextBuilder { private projectRoot: string; private queries: QueryBuilder; private traverser: GraphTraverser; constructor( projectRoot: string, queries: QueryBuilder, traverser: GraphTraverser ) { this.projectRoot = projectRoot; this.queries = queries; this.traverser = traverser; } /** * Whether the project's `codegraph.json` `deprioritize` patterns cover this * path (#982). Explore ranks through its own path scorer as well as through * `searchNodes`, so the lever has to be applied here too or the setting would * only half-work — and explore is the surface #982 actually reports on. * * Only the -15 relevance penalty is shared. Explore's hard `continue` filters * and its non-production budget cap are deliberately NOT joined: those REMOVE * content, and `deprioritize` is a ranking lever by definition — `exclude` is * the lever for taking things out of reach. * * Returned as a predicate with the patterns read once, for the candidate * loops of one search: asking the live matcher per candidate re-checked * `codegraph.json` hundreds of thousands of times on a long query (#2620). */ private deprioritizedPaths(): (filePath: string) => boolean { const matcher = this.queries.getDeprioritizedPathSnapshot?.() ?? this.queries.getDeprioritizedPathMatcher?.(); return (filePath) => matcher?.(filePath) ?? false; } /** * Build context for a task * * Pipeline: * 1. Parse task input (string or {title, description}) * 2. Run semantic search to find entry points * 3. Expand graph around entry points * 4. Extract code blocks for key nodes * 5. Format output for Claude * * @param input - Task description or object with title/description * @param options - Build options * @returns TaskContext (structured) or formatted string */ async buildContext( input: TaskInput, options: BuildContextOptions = {} ): Promise { const opts = { ...DEFAULT_BUILD_OPTIONS, ...options }; // Parse input const query = typeof input === 'string' ? input : `${input.title}${input.description ? `: ${input.description}` : ''}`; // Find relevant context (semantic search + graph expansion) const subgraph = await this.findRelevantContext(query, { searchLimit: opts.searchLimit, traversalDepth: opts.traversalDepth, maxNodes: opts.maxNodes, minScore: opts.minScore, }); // Get entry points (nodes from semantic search) const entryPoints = this.getEntryPoints(subgraph); // Extract code blocks for key nodes const codeBlocks = opts.includeCode ? await this.extractCodeBlocks(subgraph, opts.maxCodeBlocks, opts.maxCodeBlockSize) : []; // Get related files const relatedFiles = this.getRelatedFiles(subgraph); // Generate summary const summary = this.generateSummary(query, subgraph, entryPoints); // Calculate stats const stats = { nodeCount: subgraph.nodes.size, edgeCount: subgraph.edges.length, fileCount: relatedFiles.length, codeBlockCount: codeBlocks.length, totalCodeSize: codeBlocks.reduce((sum, block) => sum + block.content.length, 0), }; const context: TaskContext = { query, subgraph, entryPoints, codeBlocks, relatedFiles, summary, stats, }; // Return formatted output or raw context if (opts.format === 'markdown') { // Bounded candidate set (entry points + subgraph + code blocks), so the // DB-backed generated check is one probe, not a per-comparison query. const isGenerated = this.queries.generatedPredicateFor([ ...entryPoints.map((n) => n.filePath), ...Array.from(subgraph.nodes.values(), (n) => n.filePath), ...codeBlocks.map((b) => b.filePath), ]); return formatContextAsMarkdown(context, isGenerated) + this.buildCallPathsSection(subgraph) + (subgraph.confidence === 'low' ? this.buildLowConfidenceNote(entryPoints) : ''); } else if (opts.format === 'json') { return formatContextAsJson(context); } return context; } /** * Honest handoff appended when retrieval confidence is low (the query matched * mostly common words). Instead of the usual "this covers the surface" framing * — which, when wrong, sends the agent off to Read/Grep — it admits the * uncertainty and routes the agent to the precise tools (explore with real * symbol names, search, or files to browse the closest areas we *did* surface). */ private buildLowConfidenceNote(entryPoints: Node[]): string { const dirs: string[] = []; const seen = new Set(); for (const n of entryPoints) { const slash = n.filePath.lastIndexOf('/'); const dir = slash > 0 ? n.filePath.slice(0, slash) : n.filePath; if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); } if (dirs.length >= 4) break; } const dirLine = dirs.length ? `\n- \`codegraph_files\` a likely area: ${dirs.map(d => `\`${d}\``).join(', ')}` : ''; return `\n\n${LOW_CONFIDENCE_MARKER}\n\n` + 'This query matched mostly on common words, so the entry points above may ' + 'be off-target — treat them as a starting point, not a complete answer. ' + 'For a reliable result:\n' + '- `codegraph_explore` with the **exact symbol names** you are after ' + '(class / function / method names), or\n' + '- `codegraph_search ` for one specific symbol' + dirLine + '\n\nDo not assume the list above is comprehensive.'; } /** * Surface short call-paths among the symbols this context already found, * derived in-memory from the subgraph's `calls` edges (no extra queries). * * This bakes the value of path-finding INTO the always-loaded `context` tool. * Agents reliably read context's output but do NOT discover/adopt a standalone * trace tool (in deferred-MCP harnesses they only ToolSearch-select tools they * already know). Delivering the flow here means "how does X reach Y" is * answered without the agent needing to find, load, or choose a new tool. * Chains stop where the static call graph ends (e.g. dynamic dispatch) — that * truncation is honest, and the agent can codegraph_node the last hop to bridge. */ private buildCallPathsSection(subgraph: Subgraph): string { const adj = new Map(); for (const e of subgraph.edges) { if (e.kind !== 'calls') continue; if (!subgraph.nodes.has(e.source) || !subgraph.nodes.has(e.target)) continue; const list = adj.get(e.source); if (list) list.push(e.target); else adj.set(e.source, [e.target]); } if (adj.size === 0) return ''; const MAX_HOPS = 6; const chains: string[][] = []; let budget = 2000; // bound DFS work on dense subgraphs const dfs = (id: string, path: string[], seen: Set): void => { if (budget-- <= 0) return; const next = (adj.get(id) ?? []).filter((t) => !seen.has(t)); if (next.length === 0 || path.length >= MAX_HOPS) { if (path.length >= 3) chains.push([...path]); // >=3 nodes = a real flow, not a single call return; } for (const t of next) { seen.add(t); dfs(t, [...path, t], seen); seen.delete(t); } }; const starts = (subgraph.roots.length > 0 ? subgraph.roots.filter((id) => adj.has(id)) : [...adj.keys()] ).slice(0, 5); for (const s of starts) dfs(s, [s], new Set([s])); if (chains.length === 0) return ''; // Keep only chains that connect TWO OR MORE query-relevant symbols (roots). // A chain from a root into an arbitrary callee (render → onMagicFrameGenerate) // is structurally valid but tangential to the question; requiring ≥2 roots // keeps the chain anchored to what the user actually asked about. Rank by // #roots then length, and drop any that are a sub-path of a longer kept chain. const rootSet = new Set(subgraph.roots); const rootCount = (c: string[]): number => c.reduce((n, id) => n + (rootSet.has(id) ? 1 : 0), 0); const relevant = chains.filter((c) => rootCount(c) >= 2); relevant.sort((a, b) => rootCount(b) - rootCount(a) || b.length - a.length); const kept: string[][] = []; for (const c of relevant) { const key = c.join('>'); if (kept.some((k) => k.join('>').includes(key))) continue; kept.push(c); if (kept.length >= 3) break; } if (kept.length === 0) return ''; const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id; // Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to // static parsing — mark them inline so the agent sees WHAT bridged the hop // and WHERE it was wired up (`@file:line`) instead of grepping for it. The // wording is `graph/synthesized-hop.ts`'s, the same explore's Flow uses, plus // the detail this roomier line has space for. Keyed by "source>target". const synthByPair = new Map(); for (const e of subgraph.edges) { if (e.kind !== 'calls') continue; const hop = describeSynthesizedHop(e); if (!hop) continue; const at = hop.registeredAt ? ` @${hop.registeredAt}` : ''; synthByPair.set(`${e.source}>${e.target}`, `${hop.summary}${hop.detail}${at}`); } const renderChain = (c: string[]): string => { let s = name(c[0]!); for (let i = 1; i < c.length; i++) { const synth = synthByPair.get(`${c[i - 1]}>${c[i]}`); s += synth ? ` →[${synth}] ${name(c[i]!)}` : ` → ${name(c[i]!)}`; } return s; }; const hasSynth = kept.some((c) => c.some((_, i) => i > 0 && synthByPair.has(`${c[i - 1]}>${c[i]}`))); const lines = [ '', '## Call paths', '', 'Execution flow among the key symbols (traced through the call graph):', '', ...kept.map((c) => `- ${renderChain(c)}`), '', hasSynth ? '_Hops marked `[…]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._' : '_codegraph_node any symbol above for its source + its own callers/callees._', ]; return '\n' + lines.join('\n') + '\n'; } /** * Find relevant subgraph for a query * * Uses hybrid search combining exact symbol lookup with semantic search: * 1. Extract potential symbol names from query * 2. Look up exact matches for those symbols (high confidence) * 3. Use semantic search for concept matching * 4. Merge results, prioritizing exact matches * 5. Traverse graph from entry points * * @param query - Natural language query * @param options - Search and traversal options * @returns Subgraph of relevant nodes and edges */ async findRelevantContext( query: string, options: FindRelevantContextOptions = {} ): Promise { const opts = { ...DEFAULT_FIND_OPTIONS, ...options }; // Start with empty subgraph const nodes = new Map(); const edges: Edge[] = []; const roots: string[] = []; // Handle empty query - return empty subgraph if (!query || query.trim().length === 0) { return { nodes, edges, roots }; } // === HYBRID SEARCH === // Step 1: Extract potential symbol names from query const symbolsFromQuery = extractSymbolsFromQuery(query).slice(0, MAX_QUERY_SYMBOLS); logDebug('Extracted symbols from query', { query, symbols: symbolsFromQuery }); // Step 2: Look up exact matches for extracted symbols let exactMatches: SearchResult[] = []; if (symbolsFromQuery.length > 0 || opts.seedNames.length > 0) { try { if (symbolsFromQuery.length > 0) { // Get more results so we can apply co-location boosting before trimming exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, { limit: Math.ceil(opts.searchLimit * 5), kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, }); } // Step 2a: segment-vocabulary seeds. Word-level query terms cannot // reach camelCase names through FTS (one token per name), so the // caller resolves query words → names via the segment vocab and hands // them in as seedNames. Merged at a dampened score — a symbol the // query names outright must outrank a segment-derived one — but // BEFORE the co-location boost below, because several seeds landing // in one file (pinFeedIfNearBottom + feedAtBottom + handleFeedScroll) // is exactly the evidence that file is the answer. if (opts.seedNames.length > 0) { const seedResults = this.queries.findNodesByExactName(opts.seedNames, { limit: Math.ceil(opts.searchLimit * 3), kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, }); const known = new Set(exactMatches.map((r) => r.node.id)); for (const r of seedResults) { if (known.has(r.node.id)) continue; known.add(r.node.id); exactMatches.push({ ...r, score: r.score * 0.6 }); } logDebug('Segment seed matches', { seedNames: opts.seedNames, added: known.size }); } // Co-location boost: when multiple extracted symbols appear in the same file, // those results are much more likely to be what the user is looking for. // E.g., "scrapeLoop" + "run" both in scrape/scrape.go → boost both. if (exactMatches.length > 1) { // Build a map of files → how many distinct symbol names matched in that file const fileSymbolCounts = new Map>(); for (const r of exactMatches) { const names = fileSymbolCounts.get(r.node.filePath) || new Set(); names.add(r.node.name.toLowerCase()); fileSymbolCounts.set(r.node.filePath, names); } // Boost results in files where multiple query symbols co-occur exactMatches = exactMatches.map(r => { const symbolCount = fileSymbolCounts.get(r.node.filePath)?.size || 1; return { ...r, score: symbolCount > 1 ? r.score + (symbolCount - 1) * 20 : r.score, }; }); exactMatches.sort((a, b) => b.score - a.score); } // Trim back to reasonable size exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 2)); logDebug('Exact symbol matches', { count: exactMatches.length }); } catch (error) { logDebug('Exact symbol lookup failed', { error: String(error) }); } } // Step 2b: Search for extracted symbols as definition (class/interface) prefixes. // When the user writes "REST", "bulk", or "allocation", they usually mean classes // like RestController, BulkRequest, AllocationService — not nodes named exactly that. // Also tries stem variants: "caching" → "cache" finds Cache, CacheBuilder. if (symbolsFromQuery.length > 0) { const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias']; // Expand symbols with stem variants for broader definition matching const expandedSymbols = new Set(symbolsFromQuery); for (const sym of symbolsFromQuery) { for (const variant of getStemVariants(sym)) { expandedSymbols.add(variant); } } for (const sym of expandedSymbols) { // Title-case the symbol: "REST" → "Rest", "bulk" → "Bulk", "allocation" → "Allocation" const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); if (titleCased === sym) continue; // already title-case (e.g., "Engine") — handled by exact match // Fetch more results since popular prefixes have many matches const prefixResults = this.queries.searchNodes(titleCased, { limit: 30, kinds: definitionKinds, }); const matched: SearchResult[] = []; for (const r of prefixResults) { if (r.node.name.toLowerCase().startsWith(titleCased.toLowerCase())) { // Favor shorter names: "AllocationService" (18 chars) over // "AllocationBalancingRoundMetrics" (31 chars). Core classes tend // to have concise names; test/helper classes are verbose. const brevityBonus = Math.max(0, 10 - (r.node.name.length - titleCased.length) / 3); matched.push({ ...r, score: r.score + 15 + brevityBonus }); } } matched.sort((a, b) => b.score - a.score); for (const r of matched.slice(0, Math.ceil(opts.searchLimit))) { const existing = exactMatches.find(e => e.node.id === r.node.id); if (!existing) { exactMatches.push(r); } } } exactMatches.sort((a, b) => b.score - a.score); exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3)); } // Step 2c: Match an exact Han filename (with or without its extension). // Keep this separate from natural-language terms so partial Chinese words // do not become broad FTS queries. const filenameQuery = query.trim(); const hasHanInFilename = Array.from(filenameQuery).some( (char) => /\p{Script_Extensions=Han}/u.test(char) ); const allowsFileNodes = options.nodeKinds === undefined || options.nodeKinds.length === 0 || options.nodeKinds.includes('file'); const exactFileMatches: SearchResult[] = []; if (hasHanInFilename && allowsFileNodes) { const candidates = [ ...this.queries.getNodesByName(filenameQuery) .filter((node) => node.kind === 'file'), ...this.queries.getFileNodesByNamePrefix(`${filenameQuery}.`), ]; for (const node of candidates) { const fileName = path.basename(node.filePath); const fileExtension = path.extname(fileName); const fileStem = fileExtension ? fileName.slice(0, -fileExtension.length) : fileName; const isExactMatch = fileName === filenameQuery || fileStem === filenameQuery; if (isExactMatch) exactFileMatches.push({ node, score: 1 }); } } // Step 2d: COBOL copybook members (#2342). `COPY CVACT01Y` / `EXEC SQL // INCLUDE MYCOPYBOOK` are `import` nodes, which the default kind filter // drops — so a query naming a copybook found nothing at all, though the // include statement and the copybook are exactly what it asks for. Gathered // apart from the kind-filtered channels and admitted ahead of them below, // like an exact filename. Honors an explicit kind filter that leaves // imports out. const allowsIncludes = options.nodeKinds === undefined || options.nodeKinds.length === 0 || options.nodeKinds.includes('import'); const copybooks = allowsIncludes ? this.findCopybookEntries(query) : { entries: [], members: new Set() }; // Step 3: 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 { // A copybook member Step 2d resolved is not searched again as text: its // only FTS hits are the import nodes this channel excludes, so the search // fell through to the fuzzy fallback and returned whatever program name // sat within edit distance (`CVACT01Y` → `CBACT01C`). const searchTerms = extractSearchTerms(query) .filter((term) => !copybooks.members.has(term.toUpperCase())) .slice(0, MAX_QUERY_TERMS); if (searchTerms.length > 0) { // Search each term individually to get broader coverage, // then boost results that match multiple terms const termResultsMap = new Map(); // When no explicit kind filter is set, exclude imports — they flood FTS // results with qualified name matches (e.g., "REST" matches 445K import paths) // but are almost never what exploration queries want. const searchKinds = opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : ['file', 'module', 'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'function', 'method', 'property', 'field', 'variable', 'constant', 'enum', 'enum_member', 'type_alias', 'namespace', 'export', 'route', 'component'] as NodeKind[]; for (const term of searchTerms) { const termResults = this.queries.searchNodes(term, { limit: opts.searchLimit * 2, kinds: searchKinds, }); 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 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(); let searchResults: SearchResult[] = []; // Add exact matches first for (const result of exactMatches) { 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, upgrading scores for duplicates for (const result of textResults) { 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); } } const queryLower = query.toLowerCase(); const isTestQuery = queryLower.includes('test') || queryLower.includes('spec'); // Deprioritize test files early so they don't take multi-term boost slots if (!isTestQuery) { for (const result of searchResults) { if (isTestFile(result.node.filePath)) { result.score *= 0.3; } } } // Iter7 — Core-directory boost. On projects with one file that holds // the dense majority of internal call edges (e.g. sinatra's // `lib/sinatra/base.rb` at 85% of all in-file edges), the agent's // task usually asks about the framework's core. Without this boost, // ranking favors small focused extension files (e.g. text search // picks `sinatra-contrib/lib/sinatra/multi_route.rb`'s 10-line // `route` method over `base.rb`'s `route!` because the extension // file's `route` matches the query verbatim AND the file is small, // dwarfing the longer name `route!` in a 1500-line file). Boost // results that share a directory prefix with the dominant file's // directory so the core file's siblings outrank sibling-package // extensions. try { const dominant = this.queries.getDominantFile?.(); if (dominant && dominant.edgeCount >= 3 * dominant.nextEdgeCount) { // Take the directory of the dominant file (everything up to the // last slash). For `lib/sinatra/base.rb` → `lib/sinatra/`. const slash = dominant.filePath.lastIndexOf('/'); if (slash > 0) { const coreDir = dominant.filePath.slice(0, slash + 1); for (const result of searchResults) { if (result.node.filePath.startsWith(coreDir)) { result.score += 25; } } } } } catch { // SQL failure — fall through, scoring works without the boost } // Step 5a: Multi-term co-occurrence re-ranking (applied BEFORE truncation). // For multi-word queries like "search execution from request to shard", // nodes matching 2+ query terms in their name or path are far more relevant // than nodes matching just one generic term. Without this, "ExecutionUtils" // (matches only "execution") fills budget slots meant for "ShardSearchRequest" // (matches "shard" + "search" + "request"). const queryTermsForBoost = extractSearchTerms(query).slice(0, MAX_QUERY_TERMS); if (queryTermsForBoost.length >= 2) { // Group terms that are substrings of each other (stem variants of the same // root word). "indexed", "indexe", "index" should count as ONE concept match, // not three. Without this, stem variants inflate matchCount and give false // multi-term boosts to symbols matching one root word multiple times. const termGroups: string[][] = []; const sorted = [...queryTermsForBoost].sort((a, b) => b.length - a.length); const assigned = new Set(); for (const term of sorted) { if (assigned.has(term)) continue; const group = [term]; assigned.add(term); for (const other of sorted) { if (assigned.has(other)) continue; if (term.includes(other) || other.includes(term)) { group.push(other); assigned.add(other); } } 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)); // ...but only exempt exact matches the user *named as an identifier* // (camelCase/snake_case/acronym). A plain dictionary word that happens to // exact-match an unrelated symbol — query "flat object" → a constant named // FLAT — must NOT be exempt, or the +exact-name bonus floats it to the top // of a prose query with zero corroboration from any other term. Classify by // the QUERY token (what the user typed), not the matched symbol's name. const distinctiveTokens = new Set( symbolsFromQuery.filter(isDistinctiveIdentifier).map(s => s.toLowerCase()) ); const distinctiveExactMatchIds = new Set( exactMatches .filter(r => distinctiveTokens.has(r.node.name.toLowerCase())) .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 // "search/" but NOT "elasticsearch/". The class name is checked // separately via substring match on the node name. const nameLower = result.node.name.toLowerCase(); const dirSegments = path.dirname(result.node.filePath).toLowerCase().split('/'); let matchCount = 0; for (const group of termGroups) { const groupMatches = group.some(term => { const inName = nameLower.includes(term); const inDir = dirSegments.some(seg => seg === term); return inName || inDir; }); if (groupMatches) matchCount++; } if (matchCount >= 2) { // Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x result.score *= 1 + matchCount * 0.5; } else if (distinctiveExactMatchIds.has(result.node.id)) { // Exact match on a distinctive identifier the user explicitly named — // keep full score (e.g. "LiveEditMode DevServerPreview"). } else if (exactMatchIds.has(result.node.id)) { // Exact match on a COMMON word (e.g. "flat" → FLAT): high-scoring noise // inflated by the +exact-name bonus, corroborated by no other query // term. Demote hard so corroborated matches win. result.score *= 0.3; } else { // Mild dampen for generic single-term matches — they might be generic // but could also be the right result (e.g., "Protocol" class for an IPC query). result.score *= 0.6; } } searchResults.sort((a, b) => b.score - a.score); } // Step 5b: CamelCase-boundary matching via LIKE query. // FTS can't find "Search" inside "TransportSearchAction" (one FTS token). // LIKE reliably finds these substring matches. Results are appended with // guaranteed slots so they don't compete with higher-scoring prefix matches. if (symbolsFromQuery.length > 0) { const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias']; // Callable kinds participate too: in service-layer codebases the // camel-infix definers of a queried FIELD are methods/functions // (`profileInfo` → `getProfileInfoV2`), not classes — the type-only // whitelist made this whole step dead code there (#1196). Fetched as a // SEPARATE LIKE batch so one hot single-word term can't crowd classes // out of the length-ordered 200-row batch. const camelCallableKinds: NodeKind[] = ['function', 'method', 'component']; // A path's relevance to the query depends on nothing else, and the // candidates of every term share their files: score each path once. const isDeprioritized = this.deprioritizedPaths(); const pathScores = new Map(); const pathScoreOf = (filePath: string): number => { let score = pathScores.get(filePath); if (score === undefined) { score = scorePathRelevance(filePath, query, undefined, isDeprioritized(filePath)); pathScores.set(filePath, score); } return score; }; const camelSearchedTerms = new Set(); const searchIdSet = new Set(searchResults.map(r => r.node.id)); // Track per-node term hits for multi-term boosting const camelNodeTerms = new Map(); const maxCamelPerTerm = Math.ceil(opts.searchLimit / 2); for (const sym of symbolsFromQuery) { const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); if (titleCased.length < 3) continue; const termKey = titleCased.toLowerCase(); if (camelSearchedTerms.has(termKey)) continue; camelSearchedTerms.add(termKey); // Fetch a large batch — popular terms like "Search" in Elasticsearch // have hundreds of substring matches. The LIKE scan cost is the same // regardless of LIMIT (SQLite scans all matches to sort), so we fetch // generously and let path-relevance scoring pick the best ones. const likeResults = [ ...this.queries.findNodesByNameSubstring(titleCased, { limit: 200, kinds: camelDefinitionKinds, excludePrefix: true, }), ...this.queries.findNodesByNameSubstring(titleCased, { limit: 200, kinds: camelCallableKinds, excludePrefix: true, }), ]; // Filter to CamelCase boundaries, score by path relevance, and take top N const termCandidates: SearchResult[] = []; for (const r of likeResults) { const name = r.node.name; // Case-INSENSITIVE hump lookup: title-casing lowercases interior // humps (`profileInfo` → `Profileinfo`), which SQLite's LIKE still // matched but a case-sensitive indexOf here silently dropped — // making every multi-hump query term unfindable by this step // (#1196). The match must still LAND on an uppercase char, so a // plain lowercase infix can't slip through. const idx = name.toLowerCase().indexOf(termKey); if (idx <= 0) continue; if (!/[A-Z]/.test(name.charAt(idx))) continue; // Accept CamelCase boundary (lowercase before match) OR // acronym boundary (uppercase before match, e.g., RPCProtocol) if (!/[a-zA-Z]/.test(name.charAt(idx - 1))) continue; if (searchIdSet.has(r.node.id)) continue; if (isTestFile(r.node.filePath) && !isTestQuery) continue; const pathScore = pathScoreOf(r.node.filePath); const brevityBonus = Math.max(0, 6 - (name.length - titleCased.length) / 4); termCandidates.push({ node: r.node, score: 8 + brevityBonus + pathScore }); } termCandidates.sort((a, b) => b.score - a.score); // Widen the per-term pool for accumulation so multi-term co-occurrences // can be discovered. A class matching 3 query terms at CamelCase boundaries // is far more relevant than one matching just 1, but it needs to survive // the per-term cut for EACH term to accumulate its count. const accumPerTerm = maxCamelPerTerm * 4; for (const r of termCandidates.slice(0, accumPerTerm)) { const existing = camelNodeTerms.get(r.node.id); if (existing) { existing.termCount++; } else { camelNodeTerms.set(r.node.id, { result: r, termCount: 1, }); } } } // Append CamelCase matches with multi-term boost. // These are structurally important (class names containing query terms at // CamelCase boundaries) but score much lower than FTS results. Scale their // scores up so multi-term CamelCase matches can compete with FTS results. const camelResults: SearchResult[] = []; for (const [, info] of camelNodeTerms) { // Multi-term CamelCase matches are extremely relevant — a class matching // 3+ query terms in its name (e.g., ExtensionHostProcess) is almost // certainly what the user wants. Scale aggressively. info.result.score = info.result.score * (1 + info.termCount) + (info.termCount - 1) * 30; camelResults.push(info.result); } camelResults.sort((a, b) => b.score - a.score); const maxCamelTotal = opts.searchLimit; for (const r of camelResults.slice(0, maxCamelTotal)) { searchResults.push(r); searchIdSet.add(r.node.id); } // Step 5c: Compound term matching — find classes whose name contains 2+ // query terms at ANY position (not just CamelCase boundaries). // The CamelCase step above requires idx > 0, which misses classes that // START with a query term (e.g., "SearchShardsRequest" starts with "Search"). // For multi-word queries, a class matching multiple query terms in its name // is almost certainly relevant regardless of position. if (symbolsFromQuery.length >= 2) { // Collect ALL LIKE results per term (reusing findNodesByNameSubstring) // but without the CamelCase boundary or prefix exclusion filters. const compoundTermMap = new Map }>(); for (const sym of symbolsFromQuery) { const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); if (titleCased.length < 3) continue; const likeResults = [ ...this.queries.findNodesByNameSubstring(titleCased, { limit: 200, kinds: camelDefinitionKinds, excludePrefix: false, }), // Same separate callable batch as Step 5b (#1196). ...this.queries.findNodesByNameSubstring(titleCased, { limit: 200, kinds: camelCallableKinds, excludePrefix: false, }), ]; for (const r of likeResults) { if (searchIdSet.has(r.node.id)) continue; if (isTestFile(r.node.filePath) && !isTestQuery) continue; const entry = compoundTermMap.get(r.node.id); if (entry) { entry.terms.add(titleCased); } else { compoundTermMap.set(r.node.id, { node: r.node, terms: new Set([titleCased]) }); } } } // Keep only nodes matching 2+ distinct terms const compoundResults: SearchResult[] = []; for (const [, entry] of compoundTermMap) { if (entry.terms.size >= 2) { const pathScore = pathScoreOf(entry.node.filePath); const brevityBonus = Math.max(0, 6 - entry.node.name.length / 8); compoundResults.push({ node: entry.node, score: 10 + (entry.terms.size - 1) * 20 + pathScore + brevityBonus, }); } } compoundResults.sort((a, b) => b.score - a.score); const maxCompound = Math.ceil(opts.searchLimit / 2); for (const r of compoundResults.slice(0, maxCompound)) { searchResults.push(r); searchIdSet.add(r.node.id); } } } // Final sort and truncation — all search channels (exact, text, CamelCase, // compound) have now contributed. Sort by score so multi-term matches from // later steps can outrank dampened single-term matches from earlier steps. searchResults.sort((a, b) => b.score - a.score); searchResults = searchResults.slice(0, opts.searchLimit * 3); // Filter by minimum score let filteredResults = searchResults.filter((r) => r.score >= opts.minScore); // Resolve imports/exports to their actual definitions // If someone searches "terminal" and finds `import { TerminalPanel }`, // they want the TerminalPanel class, not the import statement filteredResults = this.resolveImportsToDefinitions(filteredResults); // An exact filename is the query's requested entry point. Keep it ahead of // broader text matches before applying the entry-point cap. if (exactFileMatches.length > 0) { const exactFileIds = new Set(exactFileMatches.map((result) => result.node.id)); filteredResults = [ ...exactFileMatches, ...filteredResults.filter((result) => !exactFileIds.has(result.node.id)), ]; } // A named copybook leads too: its own declarations, then its include // statements. When the query also matched other things, the copybook takes // at most half the entry slots so they are not capped away. const copybookIds = new Set(copybooks.entries.map((r) => r.node.id)); const leafEntryIds = new Set(); if (copybooks.entries.length > 0) { const matchedElsewhere = new Set(filteredResults.map((r) => r.node.id)); const others = filteredResults.filter((r) => !copybookIds.has(r.node.id)); // Include statements in a file the rest of the query also matched come // first ("COACTUPC CVACT01Y" wants COACTUPC's COPY, not the first one by // path). Stable, so the declarations stay ahead of every include. const otherFiles = new Set(others.map((r) => r.node.filePath)); const rank = (r: SearchResult): number => !isCopybookInclude(r.node) ? 0 : otherFiles.has(r.node.filePath) ? 1 : 2; const ordered = [...copybooks.entries].sort((a, b) => rank(a) - rank(b)); const kept = others.length > 0 ? ordered.slice(0, Math.ceil(opts.searchLimit / 2)) : ordered; // The copybook's own entries are not walked: an include statement has // nothing to walk but the program around it, and a record layout's // neighbourhood is whichever program happens to MOVE into it — walking // either spent the answer on unrelated paragraphs of an including program. for (const r of kept) if (!matchedElsewhere.has(r.node.id)) leafEntryIds.add(r.node.id); filteredResults = [...kept, ...others]; } // Cap entry points so traversal budget isn't spread too thin. // With 36 entry points and maxNodes=120, each gets only 3 nodes — useless. // Cap to searchLimit so each entry point gets a meaningful traversal budget. if (filteredResults.length > opts.searchLimit) { filteredResults = filteredResults.slice(0, opts.searchLimit); } // Confidence signal for the honest-handoff footer (consumed in buildContext). // A multi-term prose query that resolves only to isolated common-word matches // — no entry point corroborated by 2+ distinct query terms, and none a // distinctive identifier the user explicitly named — is LOW confidence: the // results are best-effort, not a located answer, so the agent should be told // to drill in with explore/trace rather than trust the list as comprehensive. // Single-keyword and symbol-name queries are exempt (their single match IS the // answer), so the handoff never fires on them. let confidence: 'high' | 'low' = 'high'; const confTerms = extractSearchTerms(query, { stems: false }).filter(t => t.length >= 3); if (confTerms.length >= 2 && filteredResults.length > 0) { const distinctive = new Set( symbolsFromQuery.filter(isDistinctiveIdentifier).map(s => s.toLowerCase()) ); const anyStrong = filteredResults.some(r => { // A copybook the query named is an exact identifier match, not prose. if (copybookIds.has(r.node.id)) return true; if (distinctive.has(r.node.name.toLowerCase())) return true; const nameLower = r.node.name.toLowerCase(); const dirSegs = path.dirname(r.node.filePath).toLowerCase().split('/'); let hits = 0; for (const t of confTerms) { if (nameLower.includes(t) || dirSegs.includes(t)) { if (++hits >= 2) return true; } } return false; }); if (!anyStrong) confidence = 'low'; } // Add entry points to subgraph for (const result of filteredResults) { nodes.set(result.node.id, result.node); roots.push(result.node.id); } // Expand type hierarchy for class/interface entry points. // BFS often exhausts its per-entry-point budget on contained methods // before reaching extends/implements neighbors. This dedicated step // ensures subclasses and superclasses always appear in results. // Budget: up to maxNodes/4 hierarchy nodes to avoid flooding. const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol']); const maxHierarchyNodes = Math.ceil(opts.maxNodes / 4); let hierarchyNodesAdded = 0; for (const result of filteredResults) { if (hierarchyNodesAdded >= maxHierarchyNodes) break; if (typeHierarchyKinds.has(result.node.kind)) { const hierarchy = this.traverser.getTypeHierarchy(result.node.id); // The budget holds per node: a widely implemented interface brings // its whole descendant tree (IDisposable: hundreds of types). for (const [id, node] of hierarchy.nodes) { if (!nodes.has(id) && hierarchyNodesAdded < maxHierarchyNodes) { nodes.set(id, node); hierarchyNodesAdded++; } } for (const edge of hierarchy.edges) { if (!nodes.has(edge.source) || !nodes.has(edge.target)) continue; const exists = edges.some( (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind ); if (!exists) { edges.push(edge); } } } } // Pass 2: expand hierarchy of newly-discovered parent types to find siblings. // E.g., InternalEngine → Engine (parent, from pass 1) → ReadOnlyEngine (sibling). if (hierarchyNodesAdded > 0) { const pass2Candidates = [...nodes.values()].filter( n => typeHierarchyKinds.has(n.kind) && !roots.includes(n.id) ); for (const candidate of pass2Candidates) { if (hierarchyNodesAdded >= maxHierarchyNodes) break; const siblingHierarchy = this.traverser.getTypeHierarchy(candidate.id); for (const [id, node] of siblingHierarchy.nodes) { if (!nodes.has(id) && hierarchyNodesAdded < maxHierarchyNodes) { nodes.set(id, node); hierarchyNodesAdded++; } } for (const edge of siblingHierarchy.edges) { if (nodes.has(edge.source) && nodes.has(edge.target)) { const exists = edges.some( (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind ); if (!exists) { edges.push(edge); } } } } } // Traverse from each entry point for (const result of filteredResults) { if (leafEntryIds.has(result.node.id)) continue; const traversalResult = this.traverser.traverseBFS(result.node.id, { maxDepth: opts.traversalDepth, edgeKinds: opts.edgeKinds && opts.edgeKinds.length > 0 ? opts.edgeKinds : undefined, nodeKinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, direction: 'both', limit: Math.ceil(opts.maxNodes / Math.max(1, filteredResults.length)), }); // Merge nodes for (const [id, node] of traversalResult.nodes) { if (!nodes.has(id)) { nodes.set(id, node); } } // Merge edges (avoid duplicates) for (const edge of traversalResult.edges) { const exists = edges.some( (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind ); if (!exists) { edges.push(edge); } } } // Trim to max nodes if needed let finalNodes = nodes; let finalEdges = edges; if (nodes.size > opts.maxNodes) { // Prioritize entry points and their direct neighbors const priorityIds = new Set(roots); for (const edge of edges) { if (priorityIds.has(edge.source)) { priorityIds.add(edge.target); } if (priorityIds.has(edge.target)) { priorityIds.add(edge.source); } } // Keep priority nodes, then fill remaining slots finalNodes = new Map(); for (const id of priorityIds) { const node = nodes.get(id); if (node && finalNodes.size < opts.maxNodes) { finalNodes.set(id, node); } } // Fill remaining from other nodes for (const [id, node] of nodes) { if (finalNodes.size >= opts.maxNodes) break; if (!finalNodes.has(id)) { finalNodes.set(id, node); } } // Filter edges to only include kept nodes finalEdges = edges.filter( (e) => finalNodes.has(e.source) && finalNodes.has(e.target) ); } // Per-file diversity cap: prevent any single file from monopolizing the // node budget. When BFS traverses from a method, it follows `contains` // to the parent class, then back down to all sibling methods. With // multiple entry points in the same class, one file can consume 30-40% // of maxNodes. Cap each file to ~20% to ensure cross-file diversity. const maxPerFile = Math.max(5, Math.ceil(opts.maxNodes * 0.2)); const fileCounts = new Map(); for (const [id, node] of finalNodes) { const ids = fileCounts.get(node.filePath) || []; ids.push(id); fileCounts.set(node.filePath, ids); } const rootSet = new Set(roots); for (const [, nodeIds] of fileCounts) { if (nodeIds.length <= maxPerFile) continue; // Sort: entry points first, then classes/interfaces, then others const kindPriority: Record = { class: 3, interface: 3, struct: 3, trait: 3, protocol: 3, enum: 3, method: 1, function: 1, property: 0, field: 0, variable: 0, }; nodeIds.sort((a, b) => { const aRoot = rootSet.has(a) ? 10 : 0; const bRoot = rootSet.has(b) ? 10 : 0; const aKind = kindPriority[finalNodes.get(a)!.kind] ?? 0; const bKind = kindPriority[finalNodes.get(b)!.kind] ?? 0; return (bRoot + bKind) - (aRoot + aKind); }); // Remove excess nodes (keep the highest-priority ones) for (const id of nodeIds.slice(maxPerFile)) { finalNodes.delete(id); } } // Non-production node cap: limit test/sample/integration/example files to // at most 15% of the budget. Many codebases have dozens of near-identical // test implementations (e.g., 6 Guard classes in integration tests) that // individually survive score dampening but collectively flood the result. // Test entry points are NOT exempt — they should be evicted too. if (!isTestQuery) { const maxNonProd = Math.max(3, Math.ceil(opts.maxNodes * 0.15)); const nonProdIds: string[] = []; for (const [id, node] of finalNodes) { if (isTestFile(node.filePath)) { nonProdIds.push(id); } } if (nonProdIds.length > maxNonProd) { for (const id of nonProdIds.slice(maxNonProd)) { finalNodes.delete(id); // Also remove from roots — test file entry points shouldn't anchor results const rootIdx = roots.indexOf(id); if (rootIdx !== -1) roots.splice(rootIdx, 1); } } } // Re-filter edges after per-file and non-production caps finalEdges = finalEdges.filter( (e) => finalNodes.has(e.source) && finalNodes.has(e.target) ); // Edge recovery: BFS with many entry points leaves most nodes disconnected. // Discover edges between already-selected nodes to recover connectivity. const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides', 'navigates']; const recoveredEdges = this.queries.findEdgesBetweenNodes( [...finalNodes.keys()], recoveryKinds, ); const existingEdgeKeys = new Set( finalEdges.map((e) => `${e.source}:${e.target}:${e.kind}`) ); for (const edge of recoveredEdges) { const key = `${edge.source}:${edge.target}:${edge.kind}`; if (!existingEdgeKeys.has(key)) { finalEdges.push(edge); existingEdgeKeys.add(key); } } return { nodes: finalNodes, edges: finalEdges, roots, confidence }; } /** * Entry points for the COBOL copybooks a query names (#2342): each indexed * copybook's top-level declarations (its record layouts, or the paragraphs * of a procedure copybook), then the include statements themselves — taken * round-robin across the named members, so one widely-included copybook * cannot crowd out another the query also names. `members` holds the * matched member names, upper-cased. Both are empty when the query names no * copybook, which is every query on a project without COBOL. */ private findCopybookEntries(query: string): { entries: SearchResult[]; members: Set } { const named = findNamedCopybooks(this.queries, query); const members = new Set(named.map((c) => c.member.toUpperCase())); const entries: SearchResult[] = []; const seen = new Set(); const add = (node: Node): void => { if (seen.has(node.id)) return; seen.add(node.id); entries.push({ node, score: 1 }); }; for (const copybook of named) { for (const file of copybook.files) { const declarationIds = this.queries.getOutgoingEdges(file.id, ['contains']).map((e) => e.target); [...this.queries.getNodesByIds(declarationIds).values()] .filter((n) => n.kind !== 'import' && n.kind !== 'export') .sort((a, b) => a.startLine - b.startLine || a.id.localeCompare(b.id)) .slice(0, MAX_COPYBOOK_DECLARATION_ENTRIES) .forEach(add); } } const longest = Math.max(0, ...named.map((c) => c.includes.length)); for (let i = 0; i < longest; i++) { for (const copybook of named) { const include = copybook.includes[i]; if (include) add(include); } } return { entries, members }; } /** * Get the source code for a node * * Reads the file and extracts the code between startLine and endLine. * * @param nodeId - ID of the node * @returns Code string or null if not found */ async getCode(nodeId: string): Promise { const node = this.queries.getNodeById(nodeId); if (!node) { return null; } return this.extractNodeCode(node); } /** * Extract code from a node's source file */ private async extractNodeCode(node: Node): Promise { // SECURITY (#383): a config-leaf node's on-disk line is `key = `. // Return the KEY only — never read the value off disk. This closes the // includeCode / buildContext code-block path, mirroring the explore source // renderer; an agent that genuinely needs a value can read the file itself. if (isConfigLeafNode(node)) { return node.signature || node.qualifiedName || node.name; } const filePath = validatePathWithinRoot(this.projectRoot, node.filePath); if (!filePath || !fs.existsSync(filePath)) { return null; } try { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); // Extract lines (1-indexed to 0-indexed) const startIdx = Math.max(0, node.startLine - 1); const endIdx = Math.min(lines.length, node.endLine); return lines.slice(startIdx, endIdx).join('\n'); } catch (error) { logDebug('Failed to extract code from node', { nodeId: node.id, filePath: node.filePath, error: String(error) }); return null; } } /** * Get entry points from a subgraph (the root nodes) */ private getEntryPoints(subgraph: Subgraph): Node[] { return subgraph.roots .map((id) => subgraph.nodes.get(id)) .filter((n): n is Node => n !== undefined); } /** * Extract code blocks for key nodes in the subgraph */ private async extractCodeBlocks( subgraph: Subgraph, maxBlocks: number, maxBlockSize: number ): Promise { const blocks: CodeBlock[] = []; // Prioritize entry points, then functions/methods const priorityNodes: Node[] = []; // First: entry points for (const id of subgraph.roots) { const node = subgraph.nodes.get(id); if (node) { priorityNodes.push(node); } } // Then: functions and methods for (const node of subgraph.nodes.values()) { if (!subgraph.roots.includes(node.id)) { if (node.kind === 'function' || node.kind === 'method') { priorityNodes.push(node); } } } // Then: classes for (const node of subgraph.nodes.values()) { if (!subgraph.roots.includes(node.id)) { if (node.kind === 'class') { priorityNodes.push(node); } } } // Extract code for priority nodes for (const node of priorityNodes) { if (blocks.length >= maxBlocks) break; const code = await this.extractNodeCode(node); if (code) { // Truncate if too long. Language-neutral marker (no `//` — not a // comment in Python, Ruby, etc.); this renders inside a fenced // source block whose language varies. const truncated = code.length > maxBlockSize ? code.slice(0, maxBlockSize) + '\n... (truncated) ...' : code; blocks.push({ content: truncated, filePath: node.filePath, startLine: node.startLine, endLine: node.endLine, language: node.language, node, }); } } return blocks; } /** * Get unique files from a subgraph */ private getRelatedFiles(subgraph: Subgraph): string[] { const files = new Set(); for (const node of subgraph.nodes.values()) { files.add(node.filePath); } return Array.from(files).sort(); } /** * Generate a summary of the context */ private generateSummary(_query: string, subgraph: Subgraph, entryPoints: Node[]): string { const nodeCount = subgraph.nodes.size; const edgeCount = subgraph.edges.length; const files = this.getRelatedFiles(subgraph); const entryPointNames = entryPoints .slice(0, 3) .map((n) => n.name) .join(', '); const remaining = entryPoints.length > 3 ? ` and ${entryPoints.length - 3} more` : ''; return `Found ${nodeCount} relevant code symbols across ${files.length} files. ` + `Key entry points: ${entryPointNames}${remaining}. ` + `${edgeCount} relationships identified.`; } /** * Resolve import/export nodes to their actual definitions * * When search returns `import { TerminalPanel }`, users want the TerminalPanel * class definition, not the import statement. This follows the `imports` edge * to find and return the actual definition instead. * * @param results - Search results that may include import/export nodes * @returns Results with imports resolved to definitions where possible */ private resolveImportsToDefinitions(results: SearchResult[]): SearchResult[] { const resolved: SearchResult[] = []; const seenIds = new Set(); for (const result of results) { const { node, score } = result; // If it's not an import/export, keep it as-is if (node.kind !== 'import' && node.kind !== 'export') { if (!seenIds.has(node.id)) { seenIds.add(node.id); resolved.push(result); } continue; } // For imports/exports, try to find what they reference // Imports have outgoing 'imports' edges to the definition // Exports have outgoing 'exports' edges to the definition const edgeKind = node.kind === 'import' ? 'imports' : 'exports'; const outgoingEdges = this.queries.getOutgoingEdges(node.id, [edgeKind as EdgeKind]); let foundDefinition = false; for (const edge of outgoingEdges) { const targetNode = this.queries.getNodeById(edge.target); if (targetNode && !seenIds.has(targetNode.id)) { // Found the definition - use it instead of the import seenIds.add(targetNode.id); resolved.push({ node: targetNode, score: score, // Preserve the original score }); foundDefinition = true; logDebug('Resolved import to definition', { import: node.name, definition: targetNode.name, kind: targetNode.kind, }); } } // If we couldn't resolve the import, skip it (it's low-value on its own) if (!foundDefinition) { logDebug('Skipping unresolved import', { name: node.name, file: node.filePath }); } } return resolved; } } /** * Create a context builder */ export function createContextBuilder( projectRoot: string, queries: QueryBuilder, traverser: GraphTraverser ): ContextBuilder { return new ContextBuilder(projectRoot, queries, traverser); } // Re-export formatter export { formatContextAsMarkdown, formatContextAsJson } from './formatter';