mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
* 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>
485 lines
21 KiB
TypeScript
485 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');
|
||
// 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;
|
||
},
|
||
},
|
||
);
|