mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
fix(impact): harden PDG impact mode
This commit is contained in:
parent
2ed8b83139
commit
99ae83103c
15 changed files with 1809 additions and 1069 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))}})\`.
|
||||
|
|
|
|||
|
|
@ -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
1080
gitnexus/src/mcp/local/pdg-impact.ts
Normal file
1080
gitnexus/src/mcp/local/pdg-impact.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
272
gitnexus/test/integration/impact-pdg-e2e.test.ts
Normal file
272
gitnexus/test/integration/impact-pdg-e2e.test.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
76
gitnexus/test/unit/pdg-impact-engine.test.ts
Normal file
76
gitnexus/test/unit/pdg-impact-engine.test.ts
Normal 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();']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue