GitNexus/gitnexus/test/integration/pdg-query.test.ts

488 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Integration Tests: MCP `pdg_query` tool (#2086 M6)
*
* End-to-end against a REAL LadybugDB: the pdg-repo fixture is indexed by the
* real pipeline with `--pdg` (workers — requires `node scripts/build.js`), the
* resulting BasicBlock nodes + CDG/REACHING_DEF edges and the fixture's
* Function symbols are persisted into the test DB, and `pdg_query` is exercised
* through the full `callTool` dispatch:
*
* - controls mode: "under what condition does X run?" (CDG), incl. the
* guard-clause subset (early-return block, #559 subsumption / R1)
* - flows mode: "where does variable Y flow?" (REACHING_DEF def→use) / R2
* - symbol + file anchoring; required-target / invalid-mode / bad-limit errors
* - a repo WITHOUT the pdg layer → the "no PDG layer" note, not an error
*
* Seeding via the real emit output (not hand-written rows) pins the format
* compatibility between the M5/M2 write path and the M6 read path — the
* BasicBlock id template + the 'T'/'F' / variable `reason` semantics.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
// No meta.json for the seeded test DB — pdg_query's meta probe degrades to
// the row-existence probe (the seeded-DB reality, like taint-explain).
loadMeta: vi.fn().mockResolvedValue(null),
};
});
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
// ─── Block 1: a --pdg index with real CDG + REACHING_DEF edges ───────
withTestLbugDB(
'pdg-query',
(handle) => {
describe('pdg_query against a --pdg index', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized in afterSetup');
backend = ext._backend;
});
it('controls mode answers "what controls X" and flags the guard clause (R1)', async () => {
const result = await backend.callTool('pdg_query', { mode: 'controls', target: 'guarded' });
expect(result).not.toHaveProperty('error');
expect(result.mode).toBe('controls');
expect(result.anchor.symbol).toBe('guarded');
expect(result.results.length).toBeGreaterThan(0);
// every edge has a 'T'/'F' branch label
for (const e of result.results) expect(['T', 'F']).toContain(e.label);
// the early `return -1` is control-dependent on the guard predicate →
// flagged guard:true (the #559 guard-clause subsumption)
const guardEdge = result.results.find((e: any) => e.guard === true);
expect(guardEdge, 'a guard-clause edge into an early-exit block').toBeDefined();
expect(guardEdge.dependent.text).toMatch(/return/);
});
it('flows mode answers "where does variable Y flow" (R2)', async () => {
const result = await backend.callTool('pdg_query', {
mode: 'flows',
target: 'loopFlow',
variable: 'sum',
});
expect(result).not.toHaveProperty('error');
expect(result.mode).toBe('flows');
expect(result.results.length).toBeGreaterThan(0);
for (const e of result.results) expect(e.variable).toBe('sum');
});
it('flows mode without a variable filter returns all def→use edges for the anchor', async () => {
const result = await backend.callTool('pdg_query', { mode: 'flows', target: 'loopFlow' });
expect(result).not.toHaveProperty('error');
expect(result.results.length).toBeGreaterThan(0);
expect(result.results.some((e: any) => e.variable === 'sum')).toBe(true);
});
it('controls mode anchors by file path too', async () => {
const result = await backend.callTool('pdg_query', {
mode: 'controls',
target: 'guards.ts',
});
expect(result).not.toHaveProperty('error');
expect(result.results.length).toBeGreaterThan(0);
});
it('rejects a missing target (PDG queries are always anchored)', async () => {
const result = await backend.callTool('pdg_query', { mode: 'controls' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/target/i);
});
it('rejects an invalid mode', async () => {
const result = await backend.callTool('pdg_query', { mode: 'slice', target: 'guarded' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/mode/i);
});
it('rejects an out-of-bounds limit', async () => {
for (const limit of [0, -1, 1.5, 10_000, NaN]) {
const result = await backend.callTool('pdg_query', {
mode: 'controls',
target: 'guarded',
limit,
});
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/limit/i);
}
});
it('an unknown symbol target mirrors context() not-found semantics', async () => {
const result = await backend.callTool('pdg_query', {
mode: 'controls',
target: 'nonexistentPdgFn999',
});
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('a call with no arguments returns a clean validation error, not a crash (#2188)', async () => {
// An MCP client may send {"name":"pdg_query"} with no `arguments` field;
// the dispatch then hands `params: undefined` to the impl. It must
// default to {} and surface the mode-validation error, not a TypeError.
const result = await backend.callTool('pdg_query', undefined);
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/mode/i);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdgq-'));
try {
fs.cpSync(FIXTURE, repoDir, { recursive: true });
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const nodes: Array<{ label: string; props: Record<string, unknown> }> = [];
pipelineResult.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') {
nodes.push({
label: 'BasicBlock',
props: {
id: n.id,
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
text: n.properties.text ?? '',
},
});
} else if (n.label === 'Function') {
nodes.push({
label: 'Function',
props: {
id: n.id,
name: n.properties.name ?? '',
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
},
});
}
});
for (const node of nodes) {
const assignments = Object.keys(node.props)
.map((k) => `${k}: $${k}`)
.join(', ');
await adapter.executePrepared(
`CREATE (n:${node.label} {${assignments}})`,
node.props as Record<string, any>,
);
}
let pdgEdges = 0;
for (const rel of pipelineResult.graph.iterRelationships()) {
if (rel.type !== 'CDG' && rel.type !== 'REACHING_DEF') continue;
await adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: '${rel.type}', confidence: $confidence, reason: $reason, step: 0}]->(b)`,
{
src: rel.sourceId,
dst: rel.targetId,
confidence: rel.confidence ?? 1.0,
reason: rel.reason ?? '',
},
);
pdgEdges++;
}
if (pdgEdges === 0) {
throw new Error('fixture produced no CDG/REACHING_DEF edges — pdg emit regressed?');
}
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'pdg-repo',
path: '/pdg/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 4, nodes: 4, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 2: a repo indexed WITHOUT --pdg ───────────────────────────
withTestLbugDB(
'pdg-query-nopdg',
(handle) => {
describe('pdg_query without a PDG layer', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized in afterSetup');
backend = ext._backend;
});
it('controls returns the status-unknown note when meta is unreadable + probe empty (#2188)', async () => {
// Meta is mocked unreadable (null) and the seed has no CDG rows. A
// missing layer is indistinguishable from an edge-free one here, so the
// note is inconclusive ("status unknown"), not the definitive absence.
const result = await backend.callTool('pdg_query', { mode: 'controls', target: 'plainFn' });
expect(result).not.toHaveProperty('error');
expect(result.results).toEqual([]);
expect(result.note).toMatch(/status unknown/i);
expect(result.note).not.toMatch(/no PDG layer/i);
expect(result.note).toContain('--pdg');
});
it('flows returns the status-unknown note too when meta is unreadable', async () => {
const result = await backend.callTool('pdg_query', { mode: 'flows', target: 'plain.ts' });
expect(result).not.toHaveProperty('error');
expect(result.results).toEqual([]);
expect(result.note).toMatch(/status unknown/i);
});
it('a readable meta without a pdg stamp short-circuits to the DEFINITIVE no-layer note', async () => {
// Meta is readable but carries no CDG cap ⇒ the layer truly was never
// recorded; this path keeps the definitive "no PDG layer" wording.
vi.mocked(loadMeta).mockResolvedValueOnce({} as any);
const result = await backend.callTool('pdg_query', { mode: 'controls', target: 'plainFn' });
expect(result.results).toEqual([]);
expect(result.note).toMatch(/no PDG layer/i);
});
});
},
{
seed: [
`CREATE (fn:Function {id: 'func:plainFn', name: 'plainFn', filePath: 'src/plain.ts', startLine: 1, endLine: 5, isExported: true, content: 'function plainFn() {}', description: 'no pdg layer here'})`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'plain-repo',
path: '/plain/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'def456',
stats: { files: 1, nodes: 1, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 3: symbol-anchor line-base off-by-one (#2188 review) ──────
//
// Hand-seeded with controlled line numbers (no parser dependency): `targetFn`
// occupies 0-based symbol lines 10–14, and a neighbor function sits directly
// above it with its last block on 1-based line 10 — the line right above
// targetFn's declaration (1-based line 11). BasicBlock startLine is 1-based
// while the symbol span is 0-based, so the anchor window must be [11,15] (both
// bounds shifted +1). The pre-fix window [10,15] (lower bound left 0-based)
// over-includes the neighbor's line-10 block. This pins the lower-bound +1.
withTestLbugDB(
'pdg-query-adjacency',
(handle) => {
describe('pdg_query symbol anchoring (#2188 lower-bound off-by-one)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized in afterSetup');
backend = ext._backend;
});
it('excludes a neighbor function block on the line directly above the target', async () => {
const result = await backend.callTool('pdg_query', {
mode: 'controls',
target: 'targetFn',
});
expect(result).not.toHaveProperty('error');
// #2380: the display anchor is 1-based, matching context/query/impact —
// targetFn stored 0-based 10 -> 11 (the BasicBlock join is unaffected).
expect((result.anchor as { startLine: number }).startLine).toBe(11);
// Only targetFn's own control edge — the neighbor's line-10 edge is out
// of the [11,15] window after the lower-bound +1 fix.
expect(result.results).toHaveLength(1);
expect(result.results[0].dependent.text).toMatch(/doThing/);
expect(result.results[0].functionLine).toBe(11);
expect(result.results.some((e: any) => /aboveDep/.test(e.dependent.text))).toBe(false);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const nodeStmts = [
`CREATE (fn:Function {id: 'func:targetFn', name: 'targetFn', filePath: 'src/adj.ts', startLine: 10, endLine: 14, isExported: true, content: 'function targetFn(x) {}', description: 'adjacency regression'})`,
// targetFn's blocks (fnStartLine segment '11', 1-based startLines 12/13)
`CREATE (b:BasicBlock {id: 'BasicBlock:src/adj.ts:11:0:0', filePath: 'src/adj.ts', startLine: 12, endLine: 12, text: 'if (x)'})`,
`CREATE (b:BasicBlock {id: 'BasicBlock:src/adj.ts:11:0:1', filePath: 'src/adj.ts', startLine: 13, endLine: 13, text: 'doThing();'})`,
// neighbor function's blocks (fnStartLine segment '9', 1-based startLine 10)
`CREATE (b:BasicBlock {id: 'BasicBlock:src/adj.ts:9:0:0', filePath: 'src/adj.ts', startLine: 10, endLine: 10, text: 'if (above)'})`,
`CREATE (b:BasicBlock {id: 'BasicBlock:src/adj.ts:9:0:1', filePath: 'src/adj.ts', startLine: 10, endLine: 10, text: 'aboveDep();'})`,
];
for (const s of nodeStmts) await adapter.executePrepared(s, {});
const cdgEdge = (src: string, dst: string) =>
adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: 'CDG', confidence: 1.0, reason: 'T', step: 0}]->(b)`,
{ src, dst },
);
await cdgEdge('BasicBlock:src/adj.ts:11:0:0', 'BasicBlock:src/adj.ts:11:0:1');
await cdgEdge('BasicBlock:src/adj.ts:9:0:0', 'BasicBlock:src/adj.ts:9:0:1');
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'adj-repo',
path: '/adj/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'adj789',
stats: { files: 1, nodes: 5, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 4: coverage gaps — ambiguous, truncated, Windows-':' path (#2188) ──
//
// Hand-seeded edge cases the M6 review flagged as untested.
withTestLbugDB(
'pdg-query-gaps',
(handle) => {
describe('pdg_query coverage gaps (#2188)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized in afterSetup');
backend = ext._backend;
});
it('an ambiguous symbol name returns ranked candidates, not a guess', async () => {
const result = await backend.callTool('pdg_query', { mode: 'controls', target: 'dupFn' });
expect(result.status).toBe('ambiguous');
expect(Array.isArray(result.candidates)).toBe(true);
expect(result.candidates.length).toBeGreaterThanOrEqual(2);
for (const c of result.candidates) {
expect(c).toHaveProperty('uid');
expect(c.name).toBe('dupFn');
expect(c).toHaveProperty('filePath');
expect(typeof c.score).toBe('number');
}
});
it('paginates: results capped at limit, total reports the full count, truncated set', async () => {
const result = await backend.callTool('pdg_query', {
mode: 'controls',
target: 'busyFn',
limit: 2,
});
expect(result).not.toHaveProperty('error');
expect(result.results).toHaveLength(2);
expect(result.total).toBe(3);
expect(result.truncated).toBe(true);
});
it('does not set truncated when the page holds every match', async () => {
const result = await backend.callTool('pdg_query', {
mode: 'controls',
target: 'busyFn',
limit: 50,
});
expect(result.results).toHaveLength(3);
expect(result.total).toBe(3);
expect(result).not.toHaveProperty('truncated');
});
it("decodes functionLine for a Windows-style filePath containing ':' (split-from-right)", async () => {
const result = await backend.callTool('pdg_query', { mode: 'controls', target: 'winFn' });
expect(result).not.toHaveProperty('error');
expect(result.results.length).toBeGreaterThan(0);
// id = BasicBlock:C:/src/win.ts:6:0:0 ⇒ fnLine segment '6' despite the
// ':' in the drive letter (fnLineOf splits from the right).
expect(result.results[0].functionLine).toBe(6);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const fn = (id: string, name: string, filePath: string, startLine: number, endLine: number) =>
adapter.executePrepared(
`CREATE (fn:Function {id: $id, name: $name, filePath: $filePath, startLine: $startLine, endLine: $endLine, isExported: true, content: 'x', description: 'gap fixture'})`,
{ id, name, filePath, startLine, endLine },
);
const block = (id: string, filePath: string, startLine: number, text: string) =>
adapter.executePrepared(
`CREATE (b:BasicBlock {id: $id, filePath: $filePath, startLine: $startLine, endLine: $startLine, text: $text})`,
{ id, filePath, startLine, text },
);
const cdg = (src: string, dst: string) =>
adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: 'CDG', confidence: 1.0, reason: 'T', step: 0}]->(b)`,
{ src, dst },
);
// (1) Ambiguous: two functions sharing a name in different files.
await fn('func:dupFn@a', 'dupFn', 'a.ts', 1, 3);
await fn('func:dupFn@b', 'dupFn', 'b.ts', 1, 3);
// (2) Truncated: busyFn (0-based 10–20 ⇒ window [11,21]); one controller
// block (line 12) with three CDG dependents.
await fn('func:busyFn', 'busyFn', 'busy.ts', 10, 20);
await block('BasicBlock:busy.ts:11:0:0', 'busy.ts', 12, 'if (x)');
await block('BasicBlock:busy.ts:11:0:1', 'busy.ts', 13, 'a();');
await block('BasicBlock:busy.ts:11:0:2', 'busy.ts', 14, 'b();');
await block('BasicBlock:busy.ts:11:0:3', 'busy.ts', 15, 'c();');
await cdg('BasicBlock:busy.ts:11:0:0', 'BasicBlock:busy.ts:11:0:1');
await cdg('BasicBlock:busy.ts:11:0:0', 'BasicBlock:busy.ts:11:0:2');
await cdg('BasicBlock:busy.ts:11:0:0', 'BasicBlock:busy.ts:11:0:3');
// (3) Windows-style path with a ':' (drive letter) inside the block id.
await fn('func:winFn', 'winFn', 'C:/src/win.ts', 5, 8);
await block('BasicBlock:C:/src/win.ts:6:0:0', 'C:/src/win.ts', 7, 'if (y)');
await block('BasicBlock:C:/src/win.ts:6:0:1', 'C:/src/win.ts', 7, 'd();');
await cdg('BasicBlock:C:/src/win.ts:6:0:0', 'BasicBlock:C:/src/win.ts:6:0:1');
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'gaps-repo',
path: '/gaps/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'gap001',
stats: { files: 4, nodes: 12, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);