mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
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>
161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|