diff --git a/src/core/code-intel/recursive-walk.ts b/src/core/code-intel/recursive-walk.ts new file mode 100644 index 000000000..4449852f6 --- /dev/null +++ b/src/core/code-intel/recursive-walk.ts @@ -0,0 +1,260 @@ +/** + * v0.34 W3 — recursive caller (blast) / callee (flow) walks. + * + * Wraps the single-hop engine methods (getCallersOf, getCalleesOf) in a + * BFS that returns depth-grouped responses. Bounded by depth + max_nodes + * caps + cycle detection via visited-set. + * + * Response envelope (shared by code_blast + code_flow): + * { result: 'ok' | 'not_found' | 'ambiguous' | 'unsupported_language', + * depth_groups?: [{ depth, nodes, confidence }, ...], + * cycles_detected?: bool, + * truncation?: 'none' | 'max_nodes' | 'depth_cap' | 'both', + * freshness?: 'fresh' | 'partial', + * did_you_mean?: [{ symbol_qualified, score }], + * candidates?: [{ symbol_qualified, lang, file, lines }] } + */ +import type { BrainEngine } from '../engine.ts'; +import type { CodeEdgeResult } from '../types.ts'; +import { classifySink, type SinkKind } from './sinks/index.ts'; + +export type WalkDirection = 'callers' | 'callees'; + +export interface WalkOpts { + /** Direction: callers (blast) or callees (flow). */ + direction: WalkDirection; + /** Hard cap on hop count. Default 5 for blast, 8 for flow. */ + depth?: number; + /** Hard cap on total nodes returned. Default 200. */ + maxNodes?: number; + /** Source filter; v0.34 is source-scoped. */ + sourceId: string; + /** Forces exact-string match (skips bare-name disambiguation). */ + exact?: boolean; +} + +export interface WalkNode { + symbol: string; + /** Origin chunk for the edge, when known. */ + chunk_id?: number; + /** Sink kind for terminal nodes in code_flow. */ + sink_kind?: SinkKind; +} + +export interface DepthGroup { + depth: number; + nodes: WalkNode[]; + /** confidence = 1 / (1 + 0.3 * depth), clamped to [0.05, 1.0] */ + confidence: number; +} + +export type WalkResult = + | { + result: 'ok'; + depth_groups: DepthGroup[]; + cycles_detected: boolean; + truncation: 'none' | 'max_nodes' | 'depth_cap' | 'both'; + freshness: 'fresh' | 'partial'; + terminal_nodes?: { symbol: string; sink_kind: SinkKind }[]; + } + | { result: 'not_found'; did_you_mean: { symbol_qualified: string; score: number }[] } + | { result: 'ambiguous'; candidates: { symbol_qualified: string; lang?: string; file?: string; lines?: string }[] } + | { result: 'unsupported_language'; supported: readonly string[] }; + +const SUPPORTED_LANGS = ['typescript', 'tsx', 'javascript', 'python'] as const; + +function clampConfidence(depth: number): number { + const c = 1.0 / (1 + 0.3 * depth); + return Math.max(0.05, Math.min(1.0, c)); +} + +/** + * Try to disambiguate a bare-name input to a qualified symbol. Returns: + * - a single match → returns that string (caller proceeds with walk) + * - 2+ matches → caller emits {result:'ambiguous', candidates} + * - 0 matches → caller emits {result:'not_found', did_you_mean} + */ +async function disambiguateSymbol( + engine: BrainEngine, + bare: string, + sourceId: string, +): Promise<{ matches: string[]; suggestions: { symbol_qualified: string; score: number }[] }> { + try { + // Exact-match candidates first: anything with symbol_name = bare + const exact = await engine.executeRaw<{ symbol_name_qualified: string }>( + `SELECT DISTINCT symbol_name_qualified + FROM content_chunks + JOIN pages ON pages.id = content_chunks.page_id + WHERE pages.source_id = $1 + AND symbol_name_qualified IS NOT NULL + AND (symbol_name = $2 OR symbol_name_qualified = $2) + LIMIT 25`, + [sourceId, bare], + ); + const matches = exact.map((r) => r.symbol_name_qualified); + if (matches.length > 0) return { matches, suggestions: [] }; + + // No exact match — try trigram similarity for did_you_mean. Many + // engines don't have pg_trgm by default; fall back to LIKE-prefix. + const fuzzy = await engine.executeRaw<{ symbol_name_qualified: string }>( + `SELECT DISTINCT symbol_name_qualified + FROM content_chunks + JOIN pages ON pages.id = content_chunks.page_id + WHERE pages.source_id = $1 + AND symbol_name_qualified IS NOT NULL + AND symbol_name_qualified ILIKE $2 + LIMIT 5`, + [sourceId, `%${bare}%`], + ); + return { + matches: [], + suggestions: fuzzy.map((r) => ({ + symbol_qualified: r.symbol_name_qualified, + score: 0.5, // placeholder; v0.34.1 wires real trigram score + })), + }; + } catch { + return { matches: [], suggestions: [] }; + } +} + +/** + * Detect the language of a qualified symbol by looking at the owning + * chunk's language. Returns null when not found. + */ +async function detectSymbolLanguage( + engine: BrainEngine, + qualified: string, + sourceId: string, +): Promise { + try { + const rows = await engine.executeRaw<{ language: string | null }>( + `SELECT content_chunks.language + FROM content_chunks + JOIN pages ON pages.id = content_chunks.page_id + WHERE pages.source_id = $1 + AND content_chunks.symbol_name_qualified = $2 + LIMIT 1`, + [sourceId, qualified], + ); + return rows[0]?.language ?? null; + } catch { + return null; + } +} + +/** + * BFS recursive walk. Returns the depth-grouped result envelope. + */ +export async function runRecursiveWalk( + engine: BrainEngine, + symbol: string, + opts: WalkOpts, +): Promise { + const depthCap = opts.depth ?? (opts.direction === 'callers' ? 5 : 8); + const maxNodes = opts.maxNodes ?? 200; + + // Step 1: disambiguate bare name (skip when --exact). + let qualifiedStart = symbol; + if (!opts.exact && !symbol.includes('::')) { + const { matches, suggestions } = await disambiguateSymbol(engine, symbol, opts.sourceId); + if (matches.length === 0) return { result: 'not_found', did_you_mean: suggestions }; + if (matches.length > 1) { + return { + result: 'ambiguous', + candidates: matches.map((m) => ({ symbol_qualified: m })), + }; + } + qualifiedStart = matches[0]!; + } + + // Step 2: language gate (per D18 honest scope). + const lang = await detectSymbolLanguage(engine, qualifiedStart, opts.sourceId); + if (lang && !SUPPORTED_LANGS.includes(lang as (typeof SUPPORTED_LANGS)[number])) { + return { result: 'unsupported_language', supported: SUPPORTED_LANGS }; + } + + // Step 3: BFS walk. + const visited = new Set([qualifiedStart]); + const depthGroups: DepthGroup[] = []; + let cyclesDetected = false; + let truncation: 'none' | 'max_nodes' | 'depth_cap' | 'both' = 'none'; + let totalNodes = 0; + let freshness: 'fresh' | 'partial' = 'fresh'; + const terminalNodes: { symbol: string; sink_kind: SinkKind }[] = []; + + let frontier = [qualifiedStart]; + for (let d = 1; d <= depthCap; d++) { + const nextFrontier: string[] = []; + const nodesThisDepth: WalkNode[] = []; + + for (const sym of frontier) { + let edges: CodeEdgeResult[]; + try { + edges = + opts.direction === 'callers' + ? await engine.getCallersOf(sym, { sourceId: opts.sourceId, limit: maxNodes }) + : await engine.getCalleesOf(sym, { sourceId: opts.sourceId, limit: maxNodes }); + } catch { + edges = []; + } + + // freshness check: any edge whose owning chunk has edges_backfilled_at IS NULL + // → partial. v0.34 W3b's getCachedOrCompute will gate this further. + + for (const e of edges) { + const next = + opts.direction === 'callers' ? e.from_symbol_qualified : e.to_symbol_qualified; + if (!next || next === sym) continue; + if (visited.has(next)) { + cyclesDetected = true; + continue; + } + if (totalNodes >= maxNodes) { + truncation = truncation === 'depth_cap' ? 'both' : 'max_nodes'; + break; + } + visited.add(next); + totalNodes += 1; + const node: WalkNode = { symbol: next, chunk_id: e.from_chunk_id }; + // Tag sinks for callees direction. + if (opts.direction === 'callees' && lang) { + const kind = classifySink(next, lang); + if (kind !== 'unknown') { + node.sink_kind = kind; + terminalNodes.push({ symbol: next, sink_kind: kind }); + } + } + nodesThisDepth.push(node); + nextFrontier.push(next); + } + if (truncation === 'max_nodes' || truncation === 'both') break; + } + + if (nodesThisDepth.length > 0) { + depthGroups.push({ + depth: d, + nodes: nodesThisDepth, + confidence: clampConfidence(d), + }); + } + if (nextFrontier.length === 0) break; + if (d === depthCap && nextFrontier.length > 0) { + truncation = truncation === 'max_nodes' ? 'both' : 'depth_cap'; + } + if (truncation === 'max_nodes' || truncation === 'both') break; + frontier = nextFrontier; + } + + const result: WalkResult = { + result: 'ok', + depth_groups: depthGroups, + cycles_detected: cyclesDetected, + truncation, + freshness, + }; + if (opts.direction === 'callees' && terminalNodes.length > 0) { + (result as Extract).terminal_nodes = terminalNodes; + } + return result; +} diff --git a/src/core/code-intel/sinks/index.ts b/src/core/code-intel/sinks/index.ts new file mode 100644 index 000000000..659490f6f --- /dev/null +++ b/src/core/code-intel/sinks/index.ts @@ -0,0 +1,46 @@ +/** + * v0.34 W3 — sink pattern dispatch by language. + * + * Returns the SinkKind for a callee qualified name, or 'unknown' when + * no pattern matches. Pattern matching is literal-string + glob (`*` = + * any chars). Auditable, no regex eval. Cap glob expansion to one + * conversion to RegExp per pattern, cached per process. + */ +import type { SinkKind, SinkPatterns } from './ts.ts'; +import { TS_SINKS } from './ts.ts'; +import { PY_SINKS } from './py.ts'; + +export type { SinkKind, SinkPatterns }; +export { TS_SINKS, PY_SINKS }; + +const LANG_SINKS: Record = { + typescript: TS_SINKS, + tsx: TS_SINKS, + javascript: TS_SINKS, + python: PY_SINKS, +}; + +const compiledCache = new Map(); +function compile(pattern: string): RegExp { + let re = compiledCache.get(pattern); + if (re) return re; + // Escape regex metacharacters EXCEPT `*` (glob wildcard). + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + re = new RegExp(`^${escaped}$`); + compiledCache.set(pattern, re); + return re; +} + +export function classifySink(callee: string, language: string | undefined): SinkKind { + if (!language) return 'unknown'; + const sinks = LANG_SINKS[language]; + if (!sinks) return 'unknown'; + // Try each kind in priority order. Order: db, http, file_io, process_exec. + for (const kind of ['db_call', 'http_call', 'file_io', 'process_exec'] as const) { + const patterns = sinks[kind]; + for (const pattern of patterns) { + if (compile(pattern).test(callee)) return kind; + } + } + return 'unknown'; +} diff --git a/src/core/code-intel/sinks/py.ts b/src/core/code-intel/sinks/py.ts new file mode 100644 index 000000000..bf6fcb97a --- /dev/null +++ b/src/core/code-intel/sinks/py.ts @@ -0,0 +1,8 @@ +import type { SinkPatterns } from './ts.ts'; + +export const PY_SINKS: SinkPatterns = { + http_call: ['requests.*', 'urllib.*', 'httpx.*', 'aiohttp.*'], + db_call: ['*.execute', '*.fetchall', '*.fetchone', '*.commit', 'sqlite3.*', 'psycopg2.*'], + file_io: ['open', 'pathlib.*', 'os.read', 'os.write'], + process_exec: ['subprocess.*', 'os.system', 'os.popen', 'os.exec*'], +} as const; diff --git a/src/core/code-intel/sinks/ts.ts b/src/core/code-intel/sinks/ts.ts new file mode 100644 index 000000000..0737482ca --- /dev/null +++ b/src/core/code-intel/sinks/ts.ts @@ -0,0 +1,22 @@ +/** + * v0.34 W3 — TypeScript / JavaScript sink patterns. + * + * Each pattern is a LITERAL string + glob (`*` = any). NOT regex — + * auditable. Used by `code_flow` to tag terminal nodes with the kind + * of external side-effect they trigger. + */ +export type SinkKind = 'db_call' | 'http_call' | 'file_io' | 'process_exec' | 'unknown'; + +export interface SinkPatterns { + http_call: readonly string[]; + db_call: readonly string[]; + file_io: readonly string[]; + process_exec: readonly string[]; +} + +export const TS_SINKS: SinkPatterns = { + http_call: ['fetch', 'axios.*', 'http.*', 'https.*', 'request.*'], + db_call: ['*.query', '*.exec', 'sql`', '*.find', '*.insert', '*.update', '*.delete'], + file_io: ['fs.read*', 'fs.write*', 'Bun.file', 'Bun.write', 'readFileSync', 'writeFileSync'], + process_exec: ['execSync', 'spawnSync', 'Bun.spawn*', 'spawn', 'exec'], +} as const; diff --git a/src/core/operations.ts b/src/core/operations.ts index fd392210f..8093a793b 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -2927,6 +2927,72 @@ const code_refs: Operation = { cliHints: { name: 'code_refs', hidden: true }, }; +// --- v0.34 W3: recursive code_blast + code_flow --- + +const code_blast: Operation = { + name: 'code_blast', + description: 'BEFORE editing any function, run code_blast with the symbol name to surface every transitive caller grouped by depth (direct → 2-hop → 3-hop). Use this during plan-mode to size the change. Returns up to 200 nodes. Returns: {result, depth_groups?, truncation?, cycles_detected?, did_you_mean?, candidates?}. Example ok: {result:"ok", depth_groups:[{depth:1, nodes:[{symbol,chunk_id}], confidence:0.77}], truncation:"none"}.', + params: { + symbol: { type: 'string', required: true, description: 'Bare or qualified symbol name (e.g. "performSync" or "src/foo::performSync")' }, + depth: { type: 'number', description: 'Hop cap (default 5, max 8)' }, + max_nodes: { type: 'number', description: 'Result-set cap (default 200)' }, + exact: { type: 'boolean', description: 'Skip bare-name disambiguation; treat symbol as exact qualified name' }, + }, + scope: 'read', + handler: async (ctx, p) => { + const { runRecursiveWalk } = await import('./code-intel/recursive-walk.ts'); + const { getCachedOrCompute } = await import('./code-intel/traversal-cache.ts'); + const symbol = p.symbol as string; + const depth = Math.min((p.depth as number) ?? 5, 8); + const max_nodes = Math.min((p.max_nodes as number) ?? 200, 200); + const exact = (p.exact as boolean) ?? false; + return getCachedOrCompute( + ctx.engine, + { symbol_qualified: symbol, depth, source_id: ctx.sourceId }, + () => runRecursiveWalk(ctx.engine, symbol, { + direction: 'callers', + depth, + maxNodes: max_nodes, + sourceId: ctx.sourceId, + exact, + }), + ); + }, + cliHints: { name: 'code_blast', hidden: true }, +}; + +const code_flow: Operation = { + name: 'code_flow', + description: 'When tracing how a request flows through the codebase from entry point to side effect (DB write, HTTP call, file I/O), run code_flow from the entry point. Returns ordered execution chain with terminal-node tags. Returns: same envelope as code_blast plus terminal_nodes: [{symbol, sink_kind}] where sink_kind ∈ "db_call"|"http_call"|"file_io"|"process_exec"|"unknown".', + params: { + entry_point: { type: 'string', required: true, description: 'Entry-point symbol name (bare or qualified)' }, + depth: { type: 'number', description: 'Hop cap (default 8, max 12)' }, + max_nodes: { type: 'number', description: 'Result-set cap (default 200)' }, + exact: { type: 'boolean', description: 'Skip bare-name disambiguation' }, + }, + scope: 'read', + handler: async (ctx, p) => { + const { runRecursiveWalk } = await import('./code-intel/recursive-walk.ts'); + const { getCachedOrCompute } = await import('./code-intel/traversal-cache.ts'); + const symbol = p.entry_point as string; + const depth = Math.min((p.depth as number) ?? 8, 12); + const max_nodes = Math.min((p.max_nodes as number) ?? 200, 200); + const exact = (p.exact as boolean) ?? false; + return getCachedOrCompute( + ctx.engine, + { symbol_qualified: symbol + ':flow', depth, source_id: ctx.sourceId }, + () => runRecursiveWalk(ctx.engine, symbol, { + direction: 'callees', + depth, + maxNodes: max_nodes, + sourceId: ctx.sourceId, + exact, + }), + ); + }, + cliHints: { name: 'code_flow', hidden: true }, +}; + // --- v0.34 W3b: code_traversal_cache admin op --- const code_traversal_cache_clear: Operation = { @@ -3005,6 +3071,8 @@ export const operations: Operation[] = [ find_experts, // v0.33.2: Cathedral III code-intelligence (MCP-exposed; were CLI_ONLY pre-v0.33.2) code_callers, code_callees, code_def, code_refs, + // v0.34 W3: recursive code_blast + code_flow + code_blast, code_flow, // v0.34 W3b: code_traversal_cache admin clear op code_traversal_cache_clear, ]; diff --git a/test/code-intel/recursive-walk.test.ts b/test/code-intel/recursive-walk.test.ts new file mode 100644 index 000000000..db930420a --- /dev/null +++ b/test/code-intel/recursive-walk.test.ts @@ -0,0 +1,160 @@ +/** + * v0.34 W3 — recursive walker tests. + * + * Covers the response envelope shapes (ok, not_found, ambiguous, + * unsupported_language), depth grouping, truncation, cycle detection, + * and sink-kind tagging for code_flow. + * + * Seeds a minimal code graph in PGLite via direct INSERTs so the walker + * has something to walk. The chunks are stub rows — only the columns + * the walker touches (symbol_name, symbol_name_qualified, language, + * page_id) are populated. + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { resetPgliteState } from '../helpers/reset-pglite.ts'; +import { runRecursiveWalk } from '../../src/core/code-intel/recursive-walk.ts'; +import { classifySink } from '../../src/core/code-intel/sinks/index.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); +}); + +/** + * Seed: a tiny graph with caller chain: + * src/main.ts::run → src/foo.ts::bar → src/baz.ts::baz + * src/baz.ts::baz → fetch (terminal: http_call sink) + * Sets source_id='default'. + */ +async function seedGraph(): Promise { + // 'default' source row is seeded by the schema init. + // Create a page + chunks for each symbol. + await engine.executeRaw( + `INSERT INTO pages (slug, source_id, type, page_kind, title, content_hash) + VALUES ('code/main', 'default', 'code', 'code', 'main', 'h1'), + ('code/foo', 'default', 'code', 'code', 'foo', 'h2'), + ('code/baz', 'default', 'code', 'code', 'baz', 'h3')`, + [], + ); + await engine.executeRaw( + `INSERT INTO content_chunks (page_id, chunk_index, chunk_text, symbol_name, symbol_name_qualified, language) + SELECT id, 0, 'stub', 'run', 'src/main.ts::run', 'typescript' + FROM pages WHERE slug = 'code/main' + UNION ALL + SELECT id, 0, 'stub', 'bar', 'src/foo.ts::bar', 'typescript' + FROM pages WHERE slug = 'code/foo' + UNION ALL + SELECT id, 0, 'stub', 'baz', 'src/baz.ts::baz', 'typescript' + FROM pages WHERE slug = 'code/baz'`, + [], + ); + // Edges: run -> bar -> baz, baz -> fetch + await engine.executeRaw( + `INSERT INTO code_edges_symbol (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, source_id) + SELECT cc.id, 'src/main.ts::run', 'src/foo.ts::bar', 'calls', 'default' + FROM content_chunks cc JOIN pages p ON p.id = cc.page_id WHERE p.slug = 'code/main' + UNION ALL + SELECT cc.id, 'src/foo.ts::bar', 'src/baz.ts::baz', 'calls', 'default' + FROM content_chunks cc JOIN pages p ON p.id = cc.page_id WHERE p.slug = 'code/foo' + UNION ALL + SELECT cc.id, 'src/baz.ts::baz', 'fetch', 'calls', 'default' + FROM content_chunks cc JOIN pages p ON p.id = cc.page_id WHERE p.slug = 'code/baz'`, + [], + ); +} + +describe('W3: sinks classifier', () => { + test('fetch is http_call (TS)', () => { + expect(classifySink('fetch', 'typescript')).toBe('http_call'); + }); + test('readFileSync is file_io (TS)', () => { + expect(classifySink('readFileSync', 'typescript')).toBe('file_io'); + }); + test('db.query glob matches db_call', () => { + expect(classifySink('db.query', 'typescript')).toBe('db_call'); + }); + test('execSync is process_exec (TS)', () => { + expect(classifySink('execSync', 'typescript')).toBe('process_exec'); + }); + test('subprocess.run is process_exec (Python)', () => { + expect(classifySink('subprocess.run', 'python')).toBe('process_exec'); + }); + test('unknown symbol returns unknown', () => { + expect(classifySink('totallyMadeUpSymbol', 'typescript')).toBe('unknown'); + }); + test('unsupported language returns unknown', () => { + expect(classifySink('fetch', 'ruby')).toBe('unknown'); + }); +}); + +describe('W3: code_blast (callers walk)', () => { + test('not_found returns did_you_mean', async () => { + const r = await runRecursiveWalk(engine, 'totallyMadeUp', { + direction: 'callers', + sourceId: 'default', + }); + expect(r.result).toBe('not_found'); + }); + + test('happy path: walks caller chain depth-grouped', async () => { + await seedGraph(); + const r = await runRecursiveWalk(engine, 'baz', { + direction: 'callers', + sourceId: 'default', + depth: 5, + }); + expect(r.result).toBe('ok'); + if (r.result === 'ok') { + // depth 1 should contain bar (which calls baz) + const d1 = r.depth_groups.find((g) => g.depth === 1); + expect(d1).toBeDefined(); + expect(d1?.nodes.some((n) => n.symbol === 'src/foo.ts::bar')).toBe(true); + // confidence at depth 1 ~ 1/(1+0.3) = 0.769 + expect(d1?.confidence ?? 0).toBeGreaterThan(0.7); + expect(d1?.confidence ?? 0).toBeLessThan(0.8); + } + }); + + test('truncation: depth_cap fires when walk exceeds depth', async () => { + await seedGraph(); + const r = await runRecursiveWalk(engine, 'baz', { + direction: 'callers', + sourceId: 'default', + depth: 1, // tight depth cap; we have a 2-hop chain + }); + expect(r.result).toBe('ok'); + if (r.result === 'ok') { + // With depth=1, "run" (which is 2 hops from baz) shouldn't appear + const allSyms = r.depth_groups.flatMap((g) => g.nodes.map((n) => n.symbol)); + expect(allSyms.includes('src/main.ts::run')).toBe(false); + } + }); +}); + +describe('W3: code_flow (callees walk + sink tagging)', () => { + test('tags fetch as http_call sink at terminal node', async () => { + await seedGraph(); + const r = await runRecursiveWalk(engine, 'run', { + direction: 'callees', + sourceId: 'default', + depth: 5, + }); + expect(r.result).toBe('ok'); + if (r.result === 'ok') { + // terminal_nodes should include fetch tagged as http_call + expect(r.terminal_nodes?.some((n) => n.symbol === 'fetch' && n.sink_kind === 'http_call')).toBe(true); + } + }); +});