mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* fix(lbug): store exact symbol content snippets * fix(ingestion): emit 0-based line numbers for COBOL/JCL/scope/markdown nodes COBOL/JCL processors, the scope-graph emitter, and the markdown Section emitter stored 1-based startLine/endLine, unlike every tree-sitter node (0-based). The exact-content slice (#2379) then dropped each symbol's declaration line for those languages. Convert to 0-based at the graph-node emission boundary via toZeroBasedLine — leaving parser-internal .line values, L${line} node/edge IDs, and containment checks untouched. Refs #2377, #2379 * refactor(lbug): single source of truth for symbol-content labels Extract SYMBOL_NODE_LABELS so the exact-content label set can't drift the way the inline copy did in #2379. csv-generator derives EXACT_SYMBOL_CONTENT_LABELS from it; manifest-extractor's near-identical allowlist is left behavior-unchanged (intentional subset, #2325-test-locked) with a documented cross-reference. Refs #2379 * test(ingestion): cover 0-based emitter output and pin exact-content slicing - csv-pipeline: replace the blank-buffer fixture (a +/-1 shift silently passed) with directly-adjacent neighbors; add one-line-symbol and Section (+/-2 fallback) cases. - cobol resolver: assert COBOL Module and JCL job/step emit 0-based startLine. - markdown CRLF: update Section startLine/endLine expectations to 0-based. Refs #2377, #2379 * feat(mcp): present 1-based line numbers in context/query/impact tools GraphNode startLine/endLine are stored 0-based (tree-sitter rows), which surprised users querying them (they don't line up with editors/sed). Add toDisplayLine and apply it at the context/query/impact response boundaries so line numbers are editor/sed-aligned. Raw cypher stays 0-based (documented in the schema resource); BasicBlock/PDG statement lines (already 1-based) and internal join params are left untouched. Refs #2377 * test(mcp): assert 1-based tool exposure with raw cypher staying 0-based context() reports startLine+1 (editor/sed aligned); a raw cypher RETURN of the same node keeps the stored 0-based value. Guards against double-conversion and leaking the display shift into raw results. Refs #2377 * fix(mcp): stop query() double-converting BM25 line numbers bm25Search applied toDisplayLine to its result rows, and query()'s aggregation loop applied it again, so BM25-matched symbols reported lines shifted +2 (stored 0-based 41 read as 43, not 42) while semantic-matched symbols were correct. bm25Search is called only from query(); return raw 0-based rows and let the single aggregation-loop conversion handle both retrievers. Adds a query() BM25 regression test asserting stored 41 -> 42 (would be 43 if double-converted), which the prior mcp-line-display test — covering only context()+cypher — never exercised. (#2380, #2377) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): use ?? not || so first-line symbols keep their line number `sym.startLine || sym[4]` treated a legitimate 0-based startLine of 0 as absent, so context()/query() dropped startLine/endLine for every symbol on line 1 of its file — every COBOL Module (toZeroBasedLine(1) = 0) and markdown h1. `??` only falls through to the positional fallback on null/undefined, preserving a real 0. This also repairs the rename definition-edit path, which consumes context()'s value. Adds a context() first-line (startLine:0 -> 1) assertion. (#2380, #2377) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): make group/cross-repo trace line numbers 1-based consistently A group/cross-repo trace presented 1-based endpoints (via resolveSymbolForGroup) but 0-based hops (tagHops copies port.trace output verbatim), so one response mixed bases. Wrap the trace port adapter (traceForGroup) to convert hop lines to 1-based too, matching the endpoints. Single-repo trace dispatches directly (not through this port) and stays 0-based — full single-repo parity is a tracked follow-up. core/group stays display-agnostic (no mcp import). Extends the cross-trace e2e test to assert hops share the endpoints' base (checkout 10 -> 11, getUsers 1 -> 2). (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): present explain/pdg_query anchor line 1-based resolveBlockAnchor converted its ambiguous-candidate lines to 1-based but left the resolved-target anchor raw 0-based, so the same tool reported two bases depending on whether the target was ambiguous. Convert the display anchor to 1-based via toDisplayLine. The BasicBlock join param (symStart: sym.startLine + 1) is untouched — it targets the 1-based BasicBlock id space, not display. Asserts the resolved anchor is 1-based (targetFn stored 10 -> 11). (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): bump schema + PDG result versions for the line-number change The 0-based storage flip for COBOL/JCL/markdown/scope (#2377/#2379) changed on-disk line semantics, and the PDG result startLine is now 1-based (#2380). Neither shipped a version bump, so an incremental re-analyze would preserve old 1-based rows (mixed-base index rendered one line too high) and PDG consumers got no signal. - INCREMENTAL_SCHEMA_VERSION 5 -> 6 (forces a one-time full re-analyze) - PDG_RESULT_VERSION 1 -> 2 (result-shape discriminator) Updates the version-pinning tests, the pdgResultVersion result type, and the tools.ts PDG output-contract doc. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(group): guard manifest label list against SYMBOL_NODE_LABELS drift manifest-extractor's CUSTOM_CONTRACT_RESOLVE_QUERY hand-lists the contract-resolvable labels as a deliberate subset of the shared SYMBOL_NODE_LABELS, guarded only by a comment — the same drift class (#2379) the shared-set refactor eliminated elsewhere. Derive the query's label set and assert it is a strict subset whose difference is exactly {Namespace, Variable, Module}, so adding a symbol label without a conscious manifest decision fails. Query string stays literal (#2325-test-locked). (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(mcp): document which tools present 1-based vs 0-based line numbers The schema-resource note listed only context/query/impact as 1-based. After the trace/anchor fixes it now enumerates the full set — context, query, impact, group/cross-repo trace, and explain/pdg_query anchors are 1-based; raw Cypher and single-repo trace stay 0-based (full single-repo-trace parity is a tracked follow-up); BasicBlock/PDG statement lines are separately 1-based. (#2377, #2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): pin impact() line-value display (close the coverage gap) The prior mcp-line-display test only asserted context() + raw cypher, which is why the query() double-conversion (#2380) shipped green. Adds an impact() line-value assertion via the ambiguous-candidate path (the only impact response that surfaces a per-candidate line): two same-name symbols force ambiguity and the candidate at stored 0-based 41 must read 42. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): fix stale rename #2283 mock after 1-based context display rename resolves its symbol via context(), which now presents startLine 1-based (#2377), then subtracts 1 to recover the 0-based file index. The #2283 mock stored startLine:1 but put `oldName` on the file's line 0, so after the 1-based shift the definition edit no longer matched and the write-failure path never fired — the test read 'success' instead of 'partial'. Align the mock content to its stored line (oldName on 0-based line 1). Pre-existing failure surfaced once ubuntu/coverage completed on this branch. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): consolidate line-display tests into one shared DB block The query()/BM25 case had spun up a second full LadybugDB + FTS setup; fold it into the single existing block (adding FTS + the Zqxwvbm seed there) so the file builds one DB, not two. Trims per-file setup cost — relevant to the Windows platform-sensitive suite's under-load 15-minute timeout. Same five assertions, all green. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kigland <shuaizhicheng336@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
488 lines
21 KiB
TypeScript
488 lines
21 KiB
TypeScript
/**
|
||
* 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');
|
||
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;
|
||
},
|
||
},
|
||
);
|