mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
feat(v0.34 W3): code_blast + code_flow recursive ops + sinks
Recursive caller (code_blast) + recursive callee (code_flow) walks land
as first-class MCP ops. The user-facing payoff for v0.34: v0.33.3
shipped flat callers/callees; v0.34 ships depth-grouped recursive walks
with cycle detection, truncation flags, freshness reporting, sink
tagging on terminal nodes, and bare-name disambiguation with
did_you_mean suggestions.
- src/core/code-intel/recursive-walk.ts: BFS over existing engine
single-hop methods (getCallersOf, getCalleesOf). Depth-grouped output;
confidence = clamp(1 / (1 + 0.3 * depth), 0.05, 1.0). Cycle detection
via visited-set; truncation enum captures both depth_cap and max_nodes
exhaustion. Source-scoped per D4 sourceId REQUIRED.
- src/core/code-intel/sinks/{ts,py,index}.ts: per-language sink patterns
as TypeScript constants (D9 — auditable literal-string + glob; NOT
regex). Pattern cache hits warm after first match per process.
TS_SINKS covers fetch, axios.*, fs.*, Bun.*, execSync, spawnSync;
PY_SINKS covers requests.*, urllib.*, subprocess.*, open, pathlib.*.
- src/core/operations.ts: code_blast + code_flow registered with
scope: 'read'. Both wrap their walks through
getCachedOrCompute (W3b) so repeat blasts in a plan-mode session hit
cache. depth + max_nodes hard-capped at handler entry per design doc
Constraints. exact: true skips bare-name disambiguation.
Response envelope (shared):
{ result: 'ok' | 'not_found' | 'ambiguous' | 'unsupported_language',
depth_groups?, cycles_detected?, truncation?, freshness?,
did_you_mean?, candidates?, supported? }
code_flow adds: terminal_nodes: [{symbol, sink_kind}] where sink_kind ∈
'db_call' | 'http_call' | 'file_io' | 'process_exec' | 'unknown'
Per D18 from eng review — only JS/TS/TSX + Python get walks. Other
languages return {result: 'unsupported_language', supported: ['ts',
'tsx','js','py']} cleanly rather than aliasing same-named callees.
test/code-intel/recursive-walk.test.ts: 11 hermetic PGLite tests:
- 7 sinks classifier cases (http_call, file_io, db_call, process_exec
for TS + Python, unknown for made-up symbol, unknown for ruby lang)
- not_found returns did_you_mean
- happy-path: caller chain emerges in depth_groups; confidence ~0.77
at depth 1
- truncation: depth_cap fires when walk exceeds depth
- sink-tagging: fetch lands in terminal_nodes with http_call kind
v0.34.0.0 scope reductions: stdio rate limiter at dispatch.ts and CLI
wrappers (gbrain blast / gbrain flow) deferred — the ops are MCP-
reachable today and the W8 release packaging step adds CLI thin-shims.
The eng-review's stdio limiter at dispatch.ts (D10) is queued behind
the eval gate run; concurrent code-intel load needed to justify it
hasn't materialized at v0.34.0.0 ship time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0c2b1da72c
commit
bfe204d871
@@ -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<string | null> {
|
||||
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<WalkResult> {
|
||||
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<string>([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<WalkResult, { result: 'ok' }>).terminal_nodes = terminalNodes;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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<string, SinkPatterns> = {
|
||||
typescript: TS_SINKS,
|
||||
tsx: TS_SINKS,
|
||||
javascript: TS_SINKS,
|
||||
python: PY_SINKS,
|
||||
};
|
||||
|
||||
const compiledCache = new Map<string, RegExp>();
|
||||
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';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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<void> {
|
||||
// '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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user