fix(impact): harden PDG impact mode

This commit is contained in:
Gergo Magyar 2026-06-18 05:21:15 +00:00
parent 2ed8b83139
commit 99ae83103c
15 changed files with 1809 additions and 1069 deletions

View file

@ -40,6 +40,28 @@ neither strictly dominates*.
> measures; the earlier "PDG is empty / callgraph wins" verdict was an artifact of
> the whole-symbol seed, now replaced.
## Runtime result contract
`impact({mode:'pdg', line:N})` success results carry a target envelope
(`id`, `name`, `type`, `filePath`), `risk: 'UNKNOWN'`, `affectedStatements`,
`affectedStatementCount`, and the same empty-safe parity fields used by callgraph
consumers (`byDepth`, `byDepthCounts`, `summary`, `affected_processes`,
`affected_modules`). The risk stays UNKNOWN because a statement slice is
intra-procedural; it is precise for the function body but not a whole-program
safety verdict.
Degraded PDG results are explicit, not empty successes. `no-layer`,
`sub-layer-missing`, and `unknown` responses keep `mode:'pdg'`, target metadata
when the target resolves, `risk:'UNKNOWN'`, a remediation note, and empty parity
fields. Truncation is also explicit: when both depth and per-step limit bounds
fire, `truncatedByReasons` reports both causes.
Deferred architecture remains out of scope for this harness: explicit
`Function|Method -> BasicBlock` containment (`CONTAINS_BLOCK`), inter-procedural
summary edges / realizable call-return paths, mutation-derived AIS, and a hybrid
callgraph+PDG impact mode are follow-up features, not assumptions of the current
statement-level benchmark.
## The corpus
Each case is a tiny self-contained TypeScript source repo plus a

View file

@ -201,7 +201,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.${
hasPdg
? ` For finer, intra-procedural precision within a function, add \`mode: "pdg"\` — it traces control/data dependence (CDG + REACHING_DEF) instead of call-graph reachability, but does NOT model cross-function impact (\`--pdg\` layer).`
? ` For finer, intra-procedural precision within a function, add \`mode: "pdg"\` with \`line: <N>\` — it returns statement-level \`affectedStatements\` over CDG + REACHING_DEF, but does NOT model cross-function impact; no-layer/degraded PDG results are UNKNOWN-risk notes (\`--pdg\` layer).`
: ''
}
- **MUST run \`detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.

View file

@ -178,6 +178,18 @@ export function formatContextResult(result: any): string {
return lines.join('\n').trim();
}
function formatTruncationSuffix(result: {
truncatedBy?: unknown;
truncatedByReasons?: unknown;
}): string {
const label = Array.isArray(result.truncatedByReasons)
? result.truncatedByReasons.join(', ')
: typeof result.truncatedBy === 'string'
? result.truncatedBy
: '';
return label ? ` (by ${label})` : '';
}
export function formatImpactResult(result: any): string {
if (result.error) {
const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
@ -194,6 +206,28 @@ export function formatImpactResult(result: any): string {
// mirroring formatContextResult, so the real impact under whichever symbol the
// caller meant is visible on the text surface, not just in the JSON.
if (result.status === 'ambiguous') {
if (result.mode === 'pdg') {
const shown = result.candidates?.length ?? 0;
const totalCandidates = result.totalCandidates ?? shown;
const countPhrase =
totalCandidates > shown
? `${totalCandidates} symbols (showing ${shown})`
: `${totalCandidates} symbols`;
const lines = [
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
`PDG impact was not computed until the target is disambiguated. ` +
`Use --uid, file_path, or kind for one authoritative PDG result.`,
];
if (result.message) lines.push(String(result.message));
for (const c of result.candidates || []) {
const score = typeof c.score === 'number' ? ` score ${c.score}` : '';
lines.push(
` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'}${score} (uid: ${c.uid})`,
);
}
return lines.join('\n');
}
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
// truncated `candidates[]` length; note when the candidate list is capped.
const shown = result.candidates?.length ?? 0;
@ -307,7 +341,7 @@ export function formatImpactResult(result: any): string {
// Truncation honesty — the slice may be a lower bound (depth or per-step
// LIMIT bound). Surface it the same way the symbol render does.
if (result.truncated) {
const by = result.truncatedBy ? ` (by ${result.truncatedBy})` : '';
const by = formatTruncationSuffix(result);
slLines.push(
`⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`,
);
@ -383,11 +417,11 @@ export function formatImpactResult(result: any): string {
if (result.unresolvedBlockCount > 0) {
pdgLines.push(
`⚠️ ${result.unresolvedBlockCount} dependence block(s) map to no owning ` +
`Function/Method (top-level statement / closure) — surfaced under their file.`,
`Function/Method/Constructor (top-level statement / closure) — surfaced under their file.`,
);
}
if (result.truncated) {
const by = result.truncatedBy ? ` (by ${result.truncatedBy})` : '';
const by = formatTruncationSuffix(result);
pdgLines.push(
`⚠️ Truncated${by} — the dependence traversal was bounded; deeper PDG impacts may exist.`,
);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,10 @@ export const EXPLAIN_MAX_LIMIT = 200;
export const PDG_QUERY_DEFAULT_LIMIT = 50;
export const PDG_QUERY_MAX_LIMIT = 200;
// Shared impact traversal depth cap. The MCP schema advertises this bound;
// PDG direct backend callers also enforce it before running traversal.
export const IMPACT_MAX_DEPTH = 32;
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
@ -413,11 +417,13 @@ MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/
STATEMENT-ANCHORED PDG SLICE: with mode:'pdg', pass "line" (1-based source line within the target symbol) to seed the dependence slice on the statement at that line and return what depends on it — the dependent statements (line + text), not the whole-symbol set. Without "line", a whole-symbol pdg slice is structurally empty (intra-procedural reach stays inside the function), so "line" is what makes pdg mode useful.
PDG OUTPUT CONTRACT: successful PDG slices include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, byDepth/byDepthCounts parity fields, risk:'UNKNOWN', and an intra-procedural note. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar.
WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
Output includes:
- risk: LOW / MEDIUM / HIGH / CRITICAL
- risk: LOW / MEDIUM / HIGH / CRITICAL / UNKNOWN
- summary: direct callers, processes affected, modules affected
- affected_processes: which execution flows break and at which step
- affected_modules: which functional areas are hit (direct vs indirect)
@ -459,7 +465,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
enum: ['callgraph', 'pdg'],
default: 'callgraph',
description:
"Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (current behavior). 'pdg' = opt-in, intra-procedural Program Dependence Graph traversal (control + data dependence); requires an index built with `gitnexus analyze --pdg`. The pdg mode is incompatible with relationTypes/crossDepth/minConfidence and with @group targets — each is rejected, not silently ignored.",
"Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (current behavior). 'pdg' = opt-in, intra-procedural Program Dependence Graph traversal (control + data dependence); requires an index built with `gitnexus analyze --pdg`. PDG success returns affectedStatements, while degraded/no-layer results return a structured UNKNOWN-risk note with target metadata when resolved. The pdg mode is incompatible with relationTypes/crossDepth/minConfidence and with @group targets — each is rejected, not silently ignored.",
},
line: {
type: 'integer',
@ -481,7 +487,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
description: 'Max relationship depth (default: 3, server clamps to 1–32)',
default: 3,
minimum: 1,
maximum: 32,
maximum: IMPACT_MAX_DEPTH,
},
crossDepth: {
type: 'number',

View file

@ -4,28 +4,25 @@
* End-to-end against a REAL LadybugDB, through the full `callTool('impact', …)`
* dispatch. Exercises the four-state PDG-layer presence/degradation check
* (`pdgLayerStatus`) wired into `_impactImpl`'s PDG branch — the check that
* fires BEFORE symbol resolution / traversal so a missing or partial `--pdg`
* layer returns a distinct guidance note instead of a confusing empty blast
* radius (or the U2-era `_runImpactPDG` "not yet implemented" stub error).
* fires after symbol resolution but before traversal so a missing or partial
* `--pdg` layer returns a distinct target-aware guidance note instead of a
* confusing empty blast radius.
*
* The four states (KTD7) are driven by what the (mocked) `loadMeta` returns —
* matching the seeded-DB reality that there is no on-disk `meta.json`:
* - no-layer : meta readable, no `pdg` stamp → run analyze --pdg
* - sub-layer-missing : exactly one cap stamped (CDG xor RD) → names the missing one
* - ready : both caps stamped → falls through to the stub
* - ready : both caps stamped → falls through to traversal
* - unknown : meta unreadable (null) → inconclusive, via 1 LIMIT 1 probe
*
* The `_runImpactPDG` traversal is still a stub in U2, so the `ready` case
* asserts the layer check let it THROUGH (the stub's "not yet implemented"
* sentinel), proving the check is ordered before the stub for the degraded
* states and falls through only when the layer is complete.
* The `ready` case asserts the layer check lets the call THROUGH to the real
* traversal, while degraded states return before `_runImpactPDG`.
*/
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import type { RepoMeta } from '../../src/storage/repo-manager.js';
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 * as poolAdapter from '../../src/core/lbug/pool-adapter.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
@ -54,6 +51,18 @@ const SEED_EDGE = `MATCH (a:BasicBlock {id: 'BasicBlock:src/hot.ts:1:0:0'}), (b:
const META = (pdg?: RepoMeta['pdg']): RepoMeta => ({ pdg }) as unknown as RepoMeta;
function expectEmptyPdgParity(result: any): void {
expect(result.mode).toBe('pdg');
expect(result.direction).toBe('downstream');
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.byDepth).toEqual({});
expect(result.byDepthCounts).toEqual({ 1: 0 });
expect(result.summary).toEqual({ direct: 0, processes_affected: 0, modules_affected: 0 });
expect(result.affected_processes).toEqual([]);
expect(result.affected_modules).toEqual([]);
}
withTestLbugDB(
'impact-pdg-degradation',
(handle) => {
@ -72,34 +81,30 @@ withTestLbugDB(
});
describe('no-layer (meta readable, no pdg stamp)', () => {
it('returns the definitive "run analyze --pdg" note — and does NOT scan the DB', async () => {
it('returns the definitive target-aware "run analyze --pdg" note', async () => {
// Readable meta with no `pdg` key ⇒ the layer was never recorded.
vi.mocked(loadMeta).mockResolvedValueOnce(META(undefined));
const spy = vi.spyOn(poolAdapter, 'executeParameterized');
spy.mockClear();
const result = await backend.callTool('impact', {
target: 'hot',
direction: 'downstream',
mode: 'pdg',
});
// Definitive, meta-derived: no DB probe ran, so executeParameterized was
// never called between the spy clear and here (the no-layer branch
// returns before the probe AND before resolveSymbolCandidates).
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
expect(result.mode).toBe('pdg');
expect(result.pdgLayer).toBe('no-layer');
expect(result.target).toEqual({
id: 'func:hot',
name: 'hot',
type: 'Function',
filePath: 'src/hot.ts',
});
expect(result.note).toMatch(/no PDG layer/i);
expect(result.note).toContain('--pdg');
// Not the stub, not a status-unknown note, not a confident LOW.
// Not a status-unknown note, not a confident LOW.
expect(result.error).toBeUndefined();
expect(result.note).not.toMatch(/status unknown/i);
expect(result.note).not.toMatch(/not yet implemented/i);
expect(result.risk).toBe('UNKNOWN');
expect(result.impactedCount).toBe(0);
expectEmptyPdgParity(result);
});
});
@ -112,11 +117,13 @@ withTestLbugDB(
mode: 'pdg',
});
expect(result.pdgLayer).toBe('sub-layer-missing');
expect(result.target.filePath).toBe('src/hot.ts');
expect(result.target.type).toBe('Function');
expect(result.missingSubLayer).toBe('REACHING_DEF');
expect(result.note).toMatch(/REACHING_DEF/);
// Partial layer must NOT be reported as complete (not the stub, no LOW).
// Partial layer must NOT be reported as complete (no LOW).
expect(result.note).not.toMatch(/not yet implemented/i);
expect(result.risk).toBe('UNKNOWN');
expectEmptyPdgParity(result);
});
it('RD present, CDG absent → names CDG as missing', async () => {
@ -132,7 +139,7 @@ withTestLbugDB(
expect(result.missingSubLayer).toBe('CDG');
expect(result.note).toMatch(/\bCDG\b/);
expect(result.note).not.toMatch(/not yet implemented/i);
expect(result.risk).toBe('UNKNOWN');
expectEmptyPdgParity(result);
});
});
@ -149,12 +156,12 @@ withTestLbugDB(
// The layer is complete, so the check did NOT short-circuit: there is no
// degradation note / pdgLayer marker — the call reached the traversal.
expect(result.pdgLayer).toBeUndefined();
// U3 landed: the stub "not yet implemented" error is gone. `hot` has a
// `hot` has a
// PDG body (blocks B0→B1) but the only dependent (B1) is itself a seed
// block of the symbol, so the intra-procedural downstream reachable set
// is empty — and that is signalled as a real traversal result with the
// distinct "has a body but no dependence" note, NOT the no-body /
// degradation path, NOT the old stub error. The load-bearing U2 fact —
// degradation path. The load-bearing U2 fact —
// `ready` does NOT return a degradation note — still holds.
expect(result.mode).toBe('pdg');
expect(result.error).toBeUndefined();
@ -177,13 +184,14 @@ withTestLbugDB(
mode: 'pdg',
});
expect(result.pdgLayer).toBe('unknown');
expect(result.target.filePath).toBe('src/hot.ts');
expect(result.target.type).toBe('Function');
expect(result.note).toMatch(/status unknown/i);
expect(result.note).toContain('--pdg');
// Inconclusive ≠ definitive no-layer wording.
expect(result.note).not.toMatch(/no PDG layer/i);
expect(result.note).not.toMatch(/not yet implemented/i);
expect(result.risk).toBe('UNKNOWN');
expect(result.impactedCount).toBe(0);
expectEmptyPdgParity(result);
});
});

View file

@ -0,0 +1,272 @@
/**
* Integration Tests: real emitter -> persisted PDG rows -> impact(mode:'pdg').
*
* Seeded PDG traversal tests lock the graph algorithm. This suite locks the
* producer/consumer contract that seeded rows cannot cover: the real analysis
* pipeline must emit BasicBlock ids, source-line metadata, and REACHING_DEF/CDG
* rows in the exact shape consumed by LocalBackend's statement-anchored PDG
* impact traversal and affectedStatements projection.
*/
import { it, expect, beforeAll, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RepoMeta } from '../../src/storage/repo-manager.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { executeParameterized } from '../../src/core/lbug/pool-adapter.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js';
const metaByStoragePath = vi.hoisted(() => new Map<string, RepoMeta | null>());
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([]),
loadMeta: vi.fn().mockImplementation(async (storagePath: string) => {
return metaByStoragePath.get(storagePath) ?? null;
}),
};
});
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
const READY_PDG_META = {
pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 },
} as unknown as RepoMeta;
async function persistFixtureGraph(
pdg: boolean,
): Promise<{ pdgEdges: number; reachingDefEdges: number; cdgEdges: number }> {
const repoDir = fs.mkdtempSync(
path.join(os.tmpdir(), pdg ? 'gn-impact-pdg-' : 'gn-impact-nopdg-'),
);
try {
fs.cpSync(FIXTURE, repoDir, { recursive: true });
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, pdg ? { pdg: true } : {});
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const nodes: Array<{ label: 'BasicBlock' | 'Function'; 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;
let reachingDefEdges = 0;
let cdgEdges = 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 (rel.type === 'REACHING_DEF') reachingDefEdges++;
if (rel.type === 'CDG') cdgEdges++;
}
return { pdgEdges, reachingDefEdges, cdgEdges };
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
}
function registerSingleRepo(handle: IndexedDBHandle, name: string, repoPath: string): void {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name,
path: repoPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'impact-pdg-e2e',
stats: { files: 4, nodes: 4, communities: 0, processes: 0 },
},
]);
}
withTestLbugDB(
'impact-pdg-e2e',
(handle) => {
let backend: LocalBackend;
let counts: { pdgEdges: number; reachingDefEdges: number; cdgEdges: number };
beforeAll(() => {
const ext = handle as typeof handle & {
_backend?: LocalBackend;
_counts?: { pdgEdges: number; reachingDefEdges: number; cdgEdges: number };
};
if (!ext._backend || !ext._counts) throw new Error('PDG e2e setup did not finish');
backend = ext._backend;
counts = ext._counts;
});
it('uses real emitted REACHING_DEF and CDG rows to return statement-level PDG impact', async () => {
expect(counts.pdgEdges).toBeGreaterThan(0);
expect(counts.reachingDefEdges).toBeGreaterThan(0);
expect(counts.cdgEdges).toBeGreaterThan(0);
const result = await backend.callTool('impact', {
target: 'loopFlow',
direction: 'downstream',
mode: 'pdg',
line: 19,
maxDepth: 10,
limit: 50,
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.target.name).toBe('loopFlow');
expect(result.target.filePath).toBe('guards.ts');
expect(result.criterionLine).toBe(19);
const persistedRd = await executeParameterized(
handle.dbPath,
`MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock)
WHERE r.type = 'REACHING_DEF'
RETURN r.type AS type
LIMIT 1`,
{},
);
expect(persistedRd.length).toBeGreaterThan(0);
const persistedCdg = await executeParameterized(
handle.dbPath,
`MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock)
WHERE r.type = 'CDG'
RETURN r.type AS type
LIMIT 1`,
{},
);
expect(persistedCdg.length).toBeGreaterThan(0);
expect(Array.isArray(result.affectedStatements)).toBe(true);
const lines = (result.affectedStatements as any[])
.map((statement) => statement.line)
.sort((a, b) => a - b);
expect(lines).toEqual(expect.arrayContaining([21, 23]));
expect(lines).not.toContain(19);
expect(result.affectedStatementCount).toBe(result.affectedStatements.length);
const controlResult = await backend.callTool('impact', {
target: 'guarded',
direction: 'downstream',
mode: 'pdg',
line: 9,
maxDepth: 10,
limit: 50,
});
expect(controlResult.error).toBeUndefined();
expect(controlResult.mode).toBe('pdg');
const controlLines = (controlResult.affectedStatements as any[])
.map((statement) => statement.line)
.sort((a, b) => a - b);
expect(controlLines).toContain(10);
expect(controlLines).not.toContain(9);
});
},
{
poolAdapter: true,
timeout: 180_000,
afterSetup: async (handle) => {
metaByStoragePath.set(handle.tmpHandle.dbPath, READY_PDG_META);
const counts = await persistFixtureGraph(true);
if (counts.pdgEdges === 0 || counts.reachingDefEdges === 0 || counts.cdgEdges === 0) {
throw new Error('fixture produced no persisted PDG dependence edges');
}
registerSingleRepo(handle, 'impact-pdg-e2e', '/impact/pdg/repo');
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
(handle as any)._counts = counts;
},
},
);
withTestLbugDB(
'impact-pdg-e2e-nopdg',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('no-PDG e2e setup did not finish');
backend = ext._backend;
});
it('returns the no-layer envelope for the same fixture indexed without PDG', async () => {
const result = await backend.callTool('impact', {
target: 'loopFlow',
direction: 'downstream',
mode: 'pdg',
line: 19,
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.pdgLayer).toBe('no-layer');
expect(result.note).toContain('--pdg');
expect(result.target).toEqual({
id: expect.any(String),
name: 'loopFlow',
type: 'Function',
filePath: 'guards.ts',
});
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.byDepthCounts).toEqual({ 1: 0 });
});
},
{
poolAdapter: true,
timeout: 180_000,
afterSetup: async (handle) => {
metaByStoragePath.set(handle.tmpHandle.dbPath, {} as RepoMeta);
await persistFixtureGraph(false);
registerSingleRepo(handle, 'impact-pdg-e2e-nopdg', '/impact/no-pdg/repo');
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);

View file

@ -25,10 +25,11 @@
* - `ctl` fn at [29,31] ⇒ blocks K1@31, K2@32 (CDG dependents of S).
* - `dupA` AND `dupB`, BOTH at 0-based [40,42] (SAME (filePath,startLine)) —
* a CDG-reachable block T@41 maps to BOTH (ambiguous-projection).
* - a free/top-level block U@99 owned by NO Function (downstream of K2) — the
* - `FlowThing.constructor` at 0-based [55,55] owns CT@56 (constructor projection).
* - a free/top-level block U@99 owned by NO symbol (downstream of K2) — the
* `unresolved` shadow path.
*
* Downstream from S: RD → {D1,D2}; CDG → {K1,K2} → T(@41) → U(@99 top-level).
* Downstream from S: RD → {D1,D2,CT}; CDG → {K1,K2} → T(@41) → U(@99 top-level).
*
* `loadMeta` is mocked to stamp BOTH caps so `pdgLayerStatus` returns `ready`.
*/
@ -63,6 +64,7 @@ const D2 = `BasicBlock:${F}:20:0:1`; // down@[19,21]
const K1 = `BasicBlock:${F}:30:0:0`; // ctl@[29,31]
const K2 = `BasicBlock:${F}:30:0:1`; // ctl@[29,31]
const T = `BasicBlock:${F}:41:0:0`; // dupA AND dupB BOTH @[40,42] → ambiguous
const CT = `BasicBlock:${F}:56:0:0`; // Constructor FlowThing.constructor@[55,55]
const U = `BasicBlock:${F}:99:0:0`; // top-level / no owning symbol → unresolved
withTestLbugDB(
@ -103,10 +105,20 @@ withTestLbugDB(
expect(down.filePath).toBe(F);
});
it('maps a reachable constructor block to its owning Constructor symbol', async () => {
const result = await downstream();
const items = Object.values(result.byDepth as Record<number, any[]>).flat();
const ctor = items.find((i: any) => i.id === 'ctor:FlowThing');
expect(ctor).toBeDefined();
expect(ctor.name).toBe('FlowThing.constructor');
expect(ctor.type).toBe('Constructor');
expect(ctor.filePath).toBe(F);
});
it('a reachable block owning NO symbol is reported as unresolved, never dropped (R9 shadow path)', async () => {
const result = await downstream();
const items = Object.values(result.byDepth as Record<number, any[]>).flat();
// U@99 has no owning Function/Method → an explicit unresolved entry.
// U@99 has no owning Function/Method/Constructor → an explicit unresolved entry.
const unresolved = items.filter((i: any) => i.id === null || i.type === 'unresolved');
expect(unresolved.length).toBeGreaterThanOrEqual(1);
expect(unresolved[0].filePath).toBe(F);
@ -186,9 +198,9 @@ withTestLbugDB(
it("risk is the existing 'UNKNOWN' sentinel, not a minted PDG label", async () => {
const result = await downstream();
expect(result.risk).toBe('UNKNOWN');
// impactedCount = distinct owning SYMBOLS (down, ctl, dupA, dupB) — the
// meaningful unit; unresolved blocks do not inflate it.
expect(result.impactedCount).toBe(4);
// impactedCount = distinct owning SYMBOLS (down, ctl, dupA, dupB, ctor) —
// the meaningful unit; unresolved blocks do not inflate it.
expect(result.impactedCount).toBe(5);
// blockCount is the raw reachable-block count, retained separately.
expect(result.blockCount).toBeGreaterThanOrEqual(result.impactedCount);
});
@ -257,6 +269,7 @@ withTestLbugDB(
expect(uids).toContain('func:target'); // from target.id
expect(uids).toContain('func:down');
expect(uids).toContain('func:ctl');
expect(uids).toContain('ctor:FlowThing');
expect(uids).not.toContain('null');
expect(uids).not.toContain('');
expect(targetFilePath).toBe(F);
@ -425,12 +438,19 @@ withTestLbugDB(
name: string,
startLine: number,
endLine: number,
type: 'Function' | 'Interface' = 'Function',
) =>
adapter.executePrepared(
type: 'Function' | 'Interface' | 'Constructor' = 'Function',
) => {
if (type === 'Constructor') {
return adapter.executePrepared(
`CREATE (n:Constructor {id: $id, name: $name, filePath: $filePath, startLine: $startLine, endLine: $endLine, content: 'x', description: 'shape fixture'})`,
{ id, name, filePath: F, startLine, endLine },
);
}
return adapter.executePrepared(
`CREATE (n:${type} {id: $id, name: $name, filePath: $filePath, startLine: $startLine, endLine: $endLine, isExported: true, content: 'x', description: 'shape fixture'})`,
{ id, name, filePath: F, startLine, endLine },
);
};
const block = (id: string, startLine: number, text: string) =>
adapter.executePrepared(
`CREATE (b:BasicBlock {id: $id, filePath: $filePath, startLine: $startLine, endLine: $startLine, text: $text})`,
@ -453,6 +473,8 @@ withTestLbugDB(
await fn('func:dupB', 'dupTarget', 40, 42);
// No-body interface (no blocks).
await fn('func:IShape', 'IShape', 50, 52, 'Interface');
// Constructor owner projection.
await fn('ctor:FlowThing', 'FlowThing.constructor', 55, 55, 'Constructor');
// Blocks.
await block(S, 11, 'const x = compute();');
@ -462,12 +484,14 @@ withTestLbugDB(
await block(K1, 31, 'doA();');
await block(K2, 32, 'doB();');
await block(T, 41, 'dispatch();'); // owned by BOTH dupA & dupB
await block(CT, 56, 'this.value = x;'); // owned by Constructor
await block(U, 99, 'top-level-side-effect();'); // owned by NO symbol
// RD chain (def→use): P → S → D1 → D2
await edge('REACHING_DEF', P, S, 'seed');
await edge('REACHING_DEF', S, D1, 'x');
await edge('REACHING_DEF', D1, D2, 'x');
await edge('REACHING_DEF', S, CT, 'ctor');
// CDG chain: P(controller) → S → K1 → K2 → T(@dup line) → U(top-level)
await edge('CDG', P, S, 'T');
await edge('CDG', S, K1, 'T');
@ -572,7 +596,7 @@ withTestLbugDB(
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'shape123',
stats: { files: 1, nodes: 16, communities: 0, processes: 0 },
stats: { files: 1, nodes: 18, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();

View file

@ -6,9 +6,9 @@
* bounded BFS over CDG + REACHING_DEF block edges — the correctness keystone of
* the feature (the KTD4 direction × edge-type truth table).
*
* The intermediate U3 payload exposes the reachable BasicBlock set:
* { mode:'pdg', target, reachableBlocks:[...ids], truncated, depthReached, note? }
* (U4 reshapes this into the consumer-safe impact result; U3 is the traversal.)
* The result exposes the consumer-safe impact shape plus traversal details
* (`reachableBlocks`, `truncated`, `depthReached`) so this suite can pin the
* graph algorithm without bypassing the public `impact` tool contract.
*
* ── Fixture graph (hand-seeded, no parser; controlled line numbers) ──────────
* One file `src/flow.ts`. The TARGET symbol `target` is a one-line function at
@ -274,6 +274,20 @@ withTestLbugDB(
expect(result.truncated).toBeFalsy();
});
it('an exact limit-sized seed/step is not flagged truncated without an extra row', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'upstream',
mode: 'pdg',
maxDepth: 10,
limit: 1,
});
expect(reachable(result)).toEqual([P]);
expect(result.truncated).toBeFalsy();
expect(result.truncatedBy).toBeUndefined();
expect(result.truncatedByReasons).toBeUndefined();
});
it('limit truncation bounds the reachable set and flags truncated', async () => {
const result = await backend.callTool('impact', {
target: 'target',
@ -288,6 +302,19 @@ withTestLbugDB(
expect(result.truncated).toBe(true);
});
it('reports both depth and limit when both bounds truncate the slice', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
maxDepth: 1,
limit: 1,
});
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe('depth');
expect(result.truncatedByReasons).toEqual(['depth', 'limit']);
});
it('rejects or clamps a negative / huge / NaN limit (validated int interpolation)', async () => {
for (const limit of [-1, NaN, 1.5]) {
const result = await backend.callTool('impact', {

View file

@ -155,6 +155,8 @@ describe('generateAIContextFiles', () => {
const withPdg = generateGitNexusContent('PdgProject', stats, { hasPdg: true });
expect(withPdg).toContain('pdg_query');
expect(withPdg).toContain('under what condition does X run');
expect(withPdg).toContain('line: <N>');
expect(withPdg).toContain('affectedStatements');
// hasPdg omitted (default false) → no pdg_query line; a non-pdg index must
// not advertise a tool that only returns a "no PDG layer" note.
const withoutPdg = generateGitNexusContent('PlainProject', stats);

View file

@ -51,7 +51,7 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
// default (so branch-scope resolution, #2106, is unaffected). The
// impact-mode block overrides it per-test to stamp a READY PDG layer, so the
// U2 layer-presence probe falls THROUGH to the post-check surface (the
// `_runImpactPDG` stub / ambiguous fan-out) those tests assert. The
// `_runImpactPDG` delegate / ambiguous fan-out) those tests assert. The
// four-state degradation contract itself is covered in
// test/integration/impact-pdg-degradation.test.ts.
loadMeta: vi.fn(actual.loadMeta),
@ -1402,15 +1402,15 @@ describe('LocalBackend.callTool', () => {
// The MCP JSON-schema enum is advisory only (server forwards args
// unvalidated, callTool is reachable directly), so the backend `mode`
// validation is load-bearing. These tests pin: callgraph is the unchanged
// default, pdg routes to the stub and NEVER the callgraph BFS, invalid modes
// hard-error, and the KTD12 incompatible params / @group targets are rejected.
// default, pdg routes to the extracted traversal and NEVER the callgraph BFS,
// invalid modes hard-error, and the KTD12 incompatible params / @group targets are rejected.
describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
let backend: LocalBackend;
// Resolve the target to a single Function so impact reaches the single-branch
// dispatch (callgraph BFS or the pdg stub). The callgraph BFS then issues
// executeQuery for its frontier; the pdg stub does not.
// dispatch (callgraph BFS or the PDG traversal). The callgraph BFS then issues
// executeQuery for its frontier; the PDG path delegates to runImpactPDG.
function resolveSingleTarget() {
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
@ -1423,7 +1423,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
// U2: stamp a READY PDG layer (both caps) so the layer-presence probe in
// `_impactImpl` falls THROUGH to the mode-dispatch surface these tests pin
// (the `_runImpactPDG` stub / the ambiguous fan-out under `mode:'pdg'`).
// (the `_runImpactPDG` delegate / the ambiguous fan-out under `mode:'pdg'`).
// Degraded-layer behavior is owned by the integration degradation suite.
vi.mocked(loadMeta).mockResolvedValue({
pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 },
@ -1437,7 +1437,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
// A clean callgraph result carries no mode/stub error and runs the BFS.
// A clean callgraph result carries no mode error and runs the BFS.
expect(result.error ?? '').not.toMatch(/Invalid "mode"/);
expect(result.error ?? '').not.toMatch(/not yet implemented/);
expect(result.target).toBeDefined();
@ -1607,6 +1607,39 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
}
});
it("unknown target with mode:'pdg' returns the normalized PDG error envelope", async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('impact', {
target: 'missingSymbol',
direction: 'upstream',
mode: 'pdg',
});
expect(result.error).toMatch(/not found/);
expect(result.mode).toBe('pdg');
expect(result.target).toEqual({ name: 'missingSymbol' });
expect(result.direction).toBe('upstream');
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
});
it("runtime failures with mode:'pdg' return the normalized PDG error envelope", async () => {
const failing = new Error('pdg query failed');
const implSpy = vi.spyOn(backend as any, '_impactImpl').mockRejectedValueOnce(failing);
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBe('pdg query failed');
expect(result.mode).toBe('pdg');
expect(result.target).toEqual({ name: 'main' });
expect(result.direction).toBe('downstream');
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.suggestion).toMatch(/context/);
implSpy.mockRestore();
});
it("@group target with mode:'pdg' is rejected (KTD12 — PDG is single-repo)", async () => {
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
const result = await backend.callTool('impact', {
@ -1616,6 +1649,11 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
repo: '@grp',
});
expect(result.error).toMatch(/not supported for @group targets/);
expect(result.mode).toBe('pdg');
expect(result.target).toEqual({ name: 'main' });
expect(result.direction).toBe('upstream');
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
});
it("@group target with mode:'callgraph' still forwards to group impact (unchanged)", async () => {

View file

@ -17,7 +17,7 @@ import { describe, expect, it } from 'vitest';
import { formatImpactResult } from '../../src/cli/eval-server.js';
// A representative PDG findings result, shaped exactly like
// `assemblePdgImpactResult` (local-backend.ts) emits.
// `assemblePdgImpactResult` (pdg-impact.ts) emits.
function pdgFindings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const items = [
{
@ -68,6 +68,44 @@ function pdgFindings(overrides: Record<string, unknown> = {}): Record<string, un
}
describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
it('renders a PDG ambiguous target without fabricated zero blast-radius counts', () => {
const out = formatImpactResult({
status: 'ambiguous',
mode: 'pdg',
message:
"Found 2 symbols matching 'login'. Disambiguate with target_uid for a single authoritative PDG result.",
target: { name: 'login' },
direction: 'upstream',
totalCandidates: 2,
impactedCount: 0,
risk: 'UNKNOWN',
candidates: [
{
uid: 'func:login:1',
name: 'login',
kind: 'Function',
filePath: 'src/auth.ts',
line: 5,
score: 1,
},
{
uid: 'func:login:2',
name: 'login',
kind: 'Function',
filePath: 'src/admin/login.ts',
line: 8,
score: 0.91,
},
],
});
expect(out).toContain('login: AMBIGUOUS');
expect(out).toContain('PDG impact was not computed');
expect(out).toContain('func:login:1');
expect(out).not.toContain('Max blast radius 0');
expect(out).not.toContain('[0 upstream');
});
it('renders findings under PDG-dependent framing, not "depth N"', () => {
const out = formatImpactResult(pdgFindings());
@ -139,6 +177,18 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
expect(out).toContain('deeper PDG impacts may exist');
});
it('renders multiple truncation causes honestly', () => {
const out = formatImpactResult(
pdgFindings({
truncated: true,
truncatedBy: 'depth',
truncatedByReasons: ['depth', 'limit'],
}),
);
expect(out).toContain('Truncated');
expect(out).toContain('by depth, limit');
});
it('renders the degradation note as remediation, not a zero/empty blast radius', () => {
// Shaped like the `_impactImpl` pdgLayer-degradation early return.
const out = formatImpactResult({

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js';
import { runImpactPDG } from '../../src/mcp/local/pdg-impact.js';
describe('runImpactPDG', () => {
it('clamps huge maxDepth values to the documented impact traversal cap', async () => {
let bfsQueries = 0;
const exec = async (_repo: string, query: string) => {
if (query.includes('MATCH (a:BasicBlock) WHERE')) {
return [{ id: 'BasicBlock:src/hot.ts:1:0:0' }];
}
if (query.includes('MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)')) {
bfsQueries += 1;
return [{ id: `BasicBlock:src/hot.ts:${bfsQueries + 1}:0:0` }];
}
if (query.includes('MATCH (b:BasicBlock) WHERE b.id IN $ids')) return [];
if (query.includes('MATCH (s:`Function`)')) return [];
return [];
};
const result = await runImpactPDG({
repo: { lbugPath: 'repo' },
sym: { id: 'func:hot', name: 'hot', filePath: 'src/hot.ts', startLine: 0, endLine: 0 },
symType: 'Function',
direction: 'downstream',
maxDepth: Number.MAX_SAFE_INTEGER,
limit: 50,
executeParameterized: exec as any,
});
expect(bfsQueries).toBe(IMPACT_MAX_DEPTH);
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe('depth');
});
it('keeps multiple reachable BasicBlocks on the same source line as separate statements', async () => {
let bfsQueries = 0;
const sameLineA = 'BasicBlock:src/hot.ts:1:0:1';
const sameLineB = 'BasicBlock:src/hot.ts:1:0:2';
const exec = async (_repo: string, query: string) => {
if (query.includes('MATCH (a:BasicBlock) WHERE')) {
return [{ id: 'BasicBlock:src/hot.ts:1:0:0' }];
}
if (query.includes('MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)')) {
bfsQueries += 1;
return bfsQueries === 1 ? [{ id: sameLineB }, { id: sameLineA }] : [];
}
if (query.includes('MATCH (b:BasicBlock) WHERE b.id IN $ids')) {
return [
{ id: sameLineB, line: 2, text: 'b();' },
{ id: sameLineA, line: 2, text: 'a();' },
];
}
if (query.includes('MATCH (s:`Function`)')) {
return [{ id: 'func:hot', name: 'hot', label: 'Function', startLine: 0 }];
}
return [];
};
const result = await runImpactPDG({
repo: { lbugPath: 'repo' },
sym: { id: 'func:hot', name: 'hot', filePath: 'src/hot.ts', startLine: 0, endLine: 3 },
symType: 'Function',
direction: 'downstream',
maxDepth: 2,
limit: 50,
line: 1,
executeParameterized: exec as any,
});
expect(result.mode).toBe('pdg');
expect((result as any).affectedStatementCount).toBe(2);
expect((result as any).affectedStatements.map((s: any) => s.line)).toEqual([2, 2]);
expect((result as any).affectedStatements.map((s: any) => s.text)).toEqual(['a();', 'b();']);
});
});

View file

@ -145,8 +145,11 @@ describe('GITNEXUS_TOOLS', () => {
// The description names the mode:'pdg' statement-anchor semantics.
expect(line.description).toMatch(/statement anchor/i);
expect(line.description).toMatch(/pdg/i);
// The top-level description mentions the statement-anchored slice.
// The top-level description mentions the statement-anchored slice and result shape.
expect(impactTool.description).toMatch(/statement-anchored|STATEMENT-ANCHORED/);
expect(impactTool.description).toContain('affectedStatements');
expect(impactTool.description).toContain('target metadata');
expect(impactTool.description).toContain('truncatedBy');
});
it('rename tool requires new_name', () => {
@ -304,6 +307,8 @@ describe('GITNEXUS_TOOLS', () => {
expect(modeProp.description).toContain('pdg');
expect(modeProp.description).toContain('--pdg');
expect(modeProp.description.toLowerCase()).toContain('intra-procedural');
expect(modeProp.description).toContain('affectedStatements');
expect(modeProp.description).toContain('UNKNOWN-risk');
// The tool-level description must mention the mode so an LLM discovers it.
expect(impactTool.description.toLowerCase()).toContain('mode');
expect(impactTool.description).toContain('pdg');