GitNexus/gitnexus/test/integration/pdg-query.test.ts
Gergő Magyar 7c3d4e6862
feat(pdg): control dependence — post-dominators + CDG (Ferrante) [M5 #2085] (#2188)
* feat(pdg): add CDG + POST_DOMINATE edge types (M5 #2085)

* feat(pdg): post-dominator tree on reverse CFG (M5 #2085)

* feat(pdg): Ferrante control-dependence over the post-dom tree (M5 #2085)

* feat(pdg): emitFileCdg + optional POST_DOMINATE debug edges (M5 #2085)

* feat(pdg): wire CDG emission in-phase + pdgModeMismatch CDG-cap stamp (M5 #2085)

* test(pdg): CDG snapshot + end-to-end pipeline answerability (M5 #2085)

* fix(review): apply autofix feedback (M5 #2085)

* fix(pdg): label CDG edges by controller arm sense, not edge kind (#2188 F1/F2/F4)

Tri-review (with Codex as the independent engine) found the CDG 'T'/'F' label
was wrong for the commonest control flow: the M1 TS visitor wires a condition's
fall-through FALSE arm as `seq`/`loop-back`, but `branchSense` mapped both to
'T', so guard clauses, if-no-else, and loop `break` got 'T' instead of 'F' (F1,
P1). The structural CDG edges were correct; only the label — the AC3 "under what
condition does X run?" answer — was wrong.

- F1: replace edge-kind `branchSense` with controller-arm-sense `labelFor`. An
  ambiguous fall-through edge (seq/loop-back) takes the COMPLEMENT of its source
  block's explicit cond-true/cond-false sibling arm. This correctly handles
  do/while (loop-back = TRUE arm) and inner-if-in-loop (loop-back = FALSE arm) —
  the ambiguity a kind→label table cannot resolve. Adds real-parser regression
  tests (the hand-built tests used a fictional cond-false edge and missed it).
- F2: correct the false "sound over-approximation that never drops a real
  dependence" claim in post-dominators.ts — exit-unreachable regions both drop
  and invent control dependences (latent for the current TS visitor, which keeps
  EXIT reverse-reachable). Reframe the exit-less-loop test to characterize, not
  bless, the degenerate behavior.
- F4: make the AC2 property-test reference compute post-dominance INDEPENDENTLY
  (node-removal reachability, no shared code with post-dominators.ts), so a
  post-dom direction bug can no longer pass both the impl and the reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): root-prettier format + run-analyze pdg stamp gains maxCdgEdgesPerFunction (#2085)

Two deterministic CI failures from the M5 CDG work:
- quality/format: basicblock-roundtrip.test.ts failed CI's root `prettier --check .`
  (the pre-commit hook uses the gitnexus-local prettier config, which differs);
  reformatted with the root config.
- tests/ubuntu/coverage: run-analyze.test.ts pinned the resolved RepoMeta.pdg
  shape (DEFAULTS) and the all-zero cap override without the new
  maxCdgEdgesPerFunction key (default 5000); added it so resolvePdgConfig
  toEqual and pdgModeMismatch(DEFAULTS) pass. (The stale-test sweep missed this
  file in PR #2188 — same trap M2 hit.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(mcp): add pdg_query tool definition (controls/flows modes) [M6 #2086]

* feat(mcp): pdg_query backend — controls (CDG) + flows (REACHING_DEF) + e2e test [M6 #2086]

* feat(mcp): document PDG edges + pdg_query (schema, cypher, skill, --pdg-gated ai-context) [M6 #2086]

* fix(mcp): correct pdg_query symbol-anchor lower bound + harden inputs [PR #2188 review]

Tri-review (Codex + adversarial + correctness lanes) of the M6 pdg_query
surface found the symbol-anchor window over-includes a neighbor function's
block. The upper bound was widened to the 1-based BasicBlock basis (symEnd+1)
but the lower bound was left 0-based, so a block on the line directly above the
target function leaked into the result. Shift both bounds +1 ([symStart+1,
symEnd+1]) so the window is the function's true block span.

Also from the same review:
- pdg_query no longer throws on a no-arguments MCP call: the dispatch passes
  raw `params`, so default it to {} → a clean mode-validation error instead of
  a TypeError. (`explain` shares this latent pattern — pre-existing follow-up.)
- tools.ts: the controls-mode description no longer hard-codes the 'F' branch
  sense for guards — `if (!ok) return;` rides the predicate's 'T' arm; the
  guard:true flag is label-agnostic (regex on the dependent block text).

Tests: a hand-seeded adjacency regression (verified failing without the
lower-bound +1) + a no-arguments validation test. Skill doc updated to document
the two-sided [symStart+1, symEnd+1] window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): drop always-true anchor conditional in pdg_query [CodeQL #2188]

CodeQL alert 756 flagged `...(anchor ? { anchor } : {})` in _pdgQueryImpl as a
useless conditional: `anchor` is unconditionally assigned in both the file-path
and symbol branches before the return (the not-found/ambiguous/no-layer paths
return earlier), so it is always truthy. Drop `| undefined` from the declaration
(TypeScript definite-assignment holds across both branches) and emit `anchor`
directly.

No runtime change — the `anchor` field was already present on every result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): add hasPdg to the noStats bridge expectation [#2188]

The M6 work threaded `hasPdg: options.pdg === true` into the AIContextOptions
passed to generateAIContextFiles on the --skills regeneration path, but this
test's strict .toEqual expectation predated it (4 keys vs 3 → CI failure). Add
`hasPdg: false` (the value on this non---pdg path). The assertion stays strict;
the #1477 noStats bridging it guards is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): collapse generateGitNexusContent params to an options bag [#2188]

The function had grown to 9 positional params; reaching `hasPdg` meant passing
six `undefined`s (the M6 review's maintainability flag). Collapse params 3-9
(generatedSkills, groupNames, noStats, skipSkills, runnerPath, defaultBranch,
hasPdg) into a `GitNexusContentOptions` object with the defaults moved to
destructuring. The body is unchanged (same local names); the single production
caller and the test calls become self-documenting named fields.

Pure refactor — generated AGENTS.md/CLAUDE.md content is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): skip CDG for exit-unreachable CFGs (unsound post-dominance) [#2188]

M5 review P2: computePostDominators roots only at cfg.exitIndex and nothing
enforced that EXIT is reachable from every block. For an entry-reachable region
that cannot reach EXIT (a non-terminating loop, or a multi-terminal CFG a future
visitor might emit) the EXIT-rooted reverse walk degenerates — it both drops
real control dependences and invents spurious ones.

Add a pure precondition predicate `isExitReachableFromAllBlocks` (co-located with
the algorithm it guards) and gate it in emitFileCdg: a CFG that violates it is
skipped for CDG (counted as skippedUnsoundFunctions + one onWarn), while its CFG
and REACHING_DEF projections — which do not depend on post-dominance — are kept.
A CDG-specific gate, not a widening of isEmitSafeCfg, so the blast radius is
exactly the unsound CDG. The current TS visitor always satisfies the
precondition (every loop gets a structural header→loopExit edge), so CDG output
for real fixtures is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): bound computeControlDependence materialization (heap parity) [#2188]

M5 review P2: unlike computeReachingDefs (maxFacts) and the emit-side edge cap,
computeControlDependence materialized the full deduped seen/out before
emitFileCdg's per-function cap could trim it — O(edges × post-dom depth) heap
for a deeply nested function.

Add a `maxEdges` ceiling (default 0 = unbounded) returning {edges, truncated},
mirroring computeReachingDefs's {facts, truncated}. The ceiling is checked
before pushing a new unique edge, so `truncated` means a genuine overflow (not
merely "reached cap"). emitFileCdg passes a FIXED materialization ceiling (8× the
default edge cap) — deliberately NOT derived from the runtime edge cap, because
CDG's materialization IS the deduped-edge quantity the cap reports on (deriving
it would pre-truncate that set and lose the exact dropped count). A ceiling hit
is surfaced via onWarn + the truncated flag — never silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): share resolveBlockAnchor; fix explain's anchor off-by-one [#2188]

M6 review P2 (duplication) + the flagged pre-existing _explainImpl correctness
follow-up. _pdgQueryImpl and _explainImpl each carried a near-identical
symbol↔block anchor resolver that had DRIFTED: pdg_query used the corrected
[symStart+1, symEnd+1] window (BasicBlock startLine is 1-based, the symbol span
0-based) while _explainImpl still used [symStart, symEnd] — dropping a taint
source on the function's final line AND leaking a neighbor's block on the line
directly above.

Extract one `resolveBlockAnchor` helper, used by both, that applies the correct
window and a single (bare) clause convention (callers compose their own WHERE).
This removes ~50 duplicated lines and fixes explain's anchor in one place.

A hand-seeded characterization test (taint-explain Block 4) pins both bounds —
verified to FAIL on the pre-fix window (it returned the line-10 neighbor instead
of the line-15 final-line source). Existing taint-explain + pdg-query suites are
unchanged (their fixtures have interior sources/sinks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): pdg_query reports "status unknown" when the layer can't be confirmed [#2188]

M6 review P3 (Codex): when meta is UNREADABLE and the bounded global existence
probe returns zero rows of the edge type, _pdgQueryImpl asserted "no PDG layer"
— but a genuinely edge-free layer (all-linear functions) is indistinguishable
from a missing one via that probe. Soften only that fallback path to an
inconclusive "PDG layer status unknown — was this repo indexed with --pdg?"
note. The meta-stamped path (stamp present, cap absent ⇒ layer truly missing)
keeps the definitive "no PDG layer" wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): cover pdg_query ambiguous / pagination / Windows-path gaps [#2188]

M6 review test-gap follow-ups, all hand-seeded with controlled data:
- ambiguous symbol name → status:'ambiguous' + ranked candidates shape
  (uid/name/filePath/score), never a silent guess;
- total/truncated page boundary in both directions (limit below the match count
  sets truncated with the full total; limit above it omits truncated);
- a Windows-style filePath containing ':' resolves and fnLineOf decodes the
  function-line segment correctly (split-from-right past the drive letter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): ship gitnexus-pdg-query skill mirrors + add pdg_query to the guide [#2086]

M6 bundled pdg_query into this PR, but the skill shipped only in the canonical
gitnexus/skills/ root. Mirror it (byte-identical) to the two hand-maintained
roots the sibling taint skill uses — .claude/skills/gitnexus/ and the plugin —
so Claude Code + plugin users get it too.

Also extend the gitnexus-guide tool reference (all 3 copies, now byte-identical):
add a `pdg_query` row + a "Control & data dependence" section mirroring the
taint/`explain` section, and reconcile the pre-existing drift where only the
.claude copy carried the `check` tool row (a real registered tool) — all three
now list it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): refresh CFG/PDG section for the full M1–M6 stack [#2086]

The PR body had deferred the "ARCHITECTURE docs refresh" to #2086; now that M6
ships here, do it:
- MCP tools table gains `explain` and `pdg_query` (were absent).
- "Optional CFG/PDG emission" was M1-only; rewrite to cover the whole opt-in
  stack — M1 CFG, M2 REACHING_DEF, M3/M4 taint, M5 CDG (Ferrante over CHK
  post-dominators, with the exit-unreachable skip), M6 read surface (pdg_query +
  explain, anchored + LIMIT-bounded, shared resolveBlockAnchor) — and note the
  no-Function→BasicBlock-edge join.
- LadybugDB schema notes the `--pdg` additions: the `BasicBlock` node table and
  the CFG/REACHING_DEF/CDG/TAINTED/SANITIZES/TAINT_PATH relation types, kept out
  of the default VALID_RELATION_TYPES / web schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:49:03 +01:00

485 lines
21 KiB
TypeScript
Raw 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 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');
// 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;
},
},
);