GitNexus/gitnexus/test/integration/pdg-query.test.ts
Gergő Magyar fbffa96554
fix(lbug/mcp): exact symbol content + 0-based line storage with 1-based MCP display (#2377, #2379) (#2380)
* 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>
2026-07-06 16:16:45 +01:00

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');
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 1014, 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 1020 ⇒ 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;
},
},
);