feat(impact): CLI rendering for PDG results (U5)

formatImpactResult gains a PDG-aware branch (detected on mode:'pdg')
that returns before any callgraph path: renders findings as
'PDG-dependent symbols' (not 'depth N'), prints the intra-procedural
caveat, surfaces ambiguous/unresolved/truncation honestly, routes the
degradation note to 'analyze --pdg' guidance and the no-body note to a
KTD6 caveat (never 'isolated'). callgraph rendering byte-identical.
ai-context adds a hasPdg-gated --mode pdg hint.

Refs U5
This commit is contained in:
Gergo Magyar 2026-06-16 08:11:24 +00:00
parent 3d12eab6f9
commit f63f3ebeac
3 changed files with 460 additions and 1 deletions

View file

@ -199,7 +199,11 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
## Always Do
- **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.
- **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).`
: ''
}
- **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))}})\`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`query({search_query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.

View file

@ -219,6 +219,128 @@ export function formatImpactResult(result: any): string {
return lines.join('\n');
}
// ─── PDG mode (mode:'pdg') ────────────────────────────────────────────
// KTD8 presentation half. PDG results are intra-procedural Program
// Dependence Graph blast radii: the single collapsed `byDepth[1]` bucket
// has NO call-hop depth meaning (block-hops ≠ call-hops), so we must NOT
// reuse the callgraph "depth N / WILL BREAK (direct)" framing, the
// callgraph DI/dynamic-dispatch lower-bound copy, or the confident
// "isolated" zero. A degraded / no-body PDG result is INCONCLUSIVE, not
// safe-to-refactor — it gets the explicit caveat + remediation, never an
// empty blast radius. Detect on `mode:'pdg'` (every PDG return path —
// findings, degradation, no-body, no-dependence — carries it). Ambiguous
// PDG results carry `status:'ambiguous'` and are handled above; they never
// reach here.
if (result.mode === 'pdg') {
const name = target?.name || '?';
// (1) Degradation — the PDG layer (or a sub-layer) is absent/unreadable.
// `pdgLayer` is the non-'ready' state from `pdgLayerStatus`. Print the
// honest remediation, NOT a zero/empty blast radius.
if (result.pdgLayer) {
const subLayer = result.missingSubLayer
? ` (missing sub-layer: ${result.missingSubLayer})`
: '';
return (
`${name}: PDG impact unavailable — the index has no usable PDG layer ` +
`[${result.pdgLayer}]${subLayer}. This is NOT "no impact". ` +
`Re-index with \`gitnexus analyze --pdg\` to build the control/data ` +
`dependence layer, or use \`--mode callgraph\` for the call-graph blast radius.` +
(result.note ? `\n${result.note}` : '')
);
}
// (2) No-body symbol (KTD6) — interface / type alias / abstract / ambient
// member / one-line declaration with no CFG. Show the caveat, never
// "isolated / no dependencies".
if (result.epistemic === 'no-pdg-body') {
return (
`${name}: PDG mode not applicable to this symbol — it has no PDG body ` +
`(no control/data dependence edges; e.g. an interface, type alias, ` +
`abstract/ambient member, or a one-line declaration). This is NOT a ` +
`confident "no impact". Use \`--mode callgraph\` for its inter-procedural ` +
`blast radius.` +
(result.note ? `\n${result.note}` : '')
);
}
const items: any[] = (result.byDepth && result.byDepth[1]) || [];
const bucketCount = result.byDepthCounts?.[1] ?? items.length;
const pdgLines: string[] = [];
// (3) Has a body but no intra-procedural dependence reachability.
// `impactedCount === 0` with no findings — still NOT "isolated": the count
// is a per-function lower bound, and inter-procedural impact is unmodeled.
if (total === 0 && bucketCount === 0) {
pdgLines.push(
`${name} (${direction}): no intra-procedural PDG-dependent symbols found. ` +
`This is NOT a confident "isolated / no dependencies" — cross-function ` +
`(inter-procedural) impact is not modeled in PDG mode. Use \`--mode callgraph\` ` +
`for the call-graph blast radius.`,
);
} else {
// (4) Findings — render the collapsed bucket under a "PDG-dependent
// symbols" heading (NOT "depth N"). `total` (impactedCount) is distinct
// owning SYMBOLS; `bucketCount` includes any `unresolved` shadow rows.
const dirLabel =
direction === 'upstream'
? 'this depends on (intra-procedural)'
: 'depend on this (intra-procedural)';
pdgLines.push(
`PDG-dependent symbols for ${target?.kind || ''} ${name} (${direction}): ` +
`${total} symbol(s) ${dirLabel}`,
);
pdgLines.push('');
const shown = Math.min(items.length, 12);
for (const item of items.slice(0, shown)) {
const flags: string[] = [];
if (item.unresolved) flags.push('unresolved');
if (item.ambiguous) flags.push('ambiguous');
const flagStr = flags.length ? ` [${flags.join(', ')}]` : '';
pdgLines.push(` ${item.type || ''} ${item.name} → ${item.filePath}${flagStr}`);
}
if (bucketCount > shown) {
pdgLines.push(` ... and ${bucketCount - shown} more`);
}
}
// Intra-procedural caveat — always present for a non-degraded PDG result.
// The assembled `note` already carries the cross-function caveat + the
// ambiguous/unresolved breakdown; surface it verbatim so the CLI reader
// sees the same honesty the JSON consumer does.
if (result.note) {
pdgLines.push('');
pdgLines.push(`ℹ️ ${result.note}`);
} else {
pdgLines.push('');
pdgLines.push(
"ℹ️ Intra-procedural Program Dependence Graph — cross-function impact is not modeled in this mode.",
);
}
// Honest incompleteness signals (block-attribution + truncation).
if (result.ambiguousProjectionCount > 0) {
pdgLines.push(
`⚠️ ${result.ambiguousProjectionCount} block(s) could not be attributed to a ` +
`unique owning symbol (same-line functions) — all colliding symbols are shown.`,
);
}
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.`,
);
}
if (result.truncated) {
const by = result.truncatedBy ? ` (by ${result.truncatedBy})` : '';
pdgLines.push(
`⚠️ Truncated${by} — the dependence traversal was bounded; deeper PDG impacts may exist.`,
);
}
return pdgLines.join('\n').trim();
}
if (total === 0) {
// #1858 — "isolated" is a confident claim. If an interface / indirection
// boundary is on the path, the true count is a lower bound, not zero;

View file

@ -0,0 +1,333 @@
/**
* U5 — CLI / consumer rendering for PDG (`mode:'pdg'`) impact results.
*
* Guards the KTD8 presentation contract: PDG results must render HONESTLY —
* - findings under a "PDG-dependent symbols" heading, NOT "depth N"
* (block-hops are not call-hops);
* - the intra-procedural caveat ("cross-function impact not modeled");
* - degradation → the "run analyze --pdg" remediation, NOT a zero blast radius;
* - no-body (KTD6) → the "not applicable to this symbol kind" caveat, NOT
* "isolated / no dependencies";
* - the callgraph DI/dynamic-dispatch epistemic copy is NEVER printed for PDG.
*
* And the standing interchangeability contract (KTD8): `mode:'callgraph'`
* rendering stays byte-identical (regression guard).
*/
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.
function pdgFindings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const items = [
{
depth: 1,
id: 'Function:src/svc.ts:applyDiscount',
name: 'applyDiscount',
type: 'Function',
filePath: 'src/svc.ts',
processes: [],
},
{
depth: 1,
id: 'Function:src/svc.ts:finalizeTotal',
name: 'finalizeTotal',
type: 'Function',
filePath: 'src/svc.ts',
processes: [],
},
];
return {
mode: 'pdg',
target: {
id: 'Function:src/svc.ts:computeTotal',
name: 'computeTotal',
type: 'Function',
filePath: 'src/svc.ts',
},
direction: 'downstream',
impactedCount: 2,
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
note:
"mode:'pdg' — intra-procedural Program Dependence Graph. 2 owning symbols reached via 4 " +
'dependence blocks (downstream over CDG + REACHING_DEF). Cross-function (inter-procedural) ' +
"impact is NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.",
reachableBlocks: ['b1', 'b2', 'b3', 'b4'],
blockCount: 4,
depthReached: 2,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
summary: { direct: 2, processes_affected: 0, modules_affected: 0 },
byDepthCounts: { 1: 2 },
affected_processes: [],
affected_modules: [],
byDepth: { 1: items },
...overrides,
};
}
describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
it('renders findings under PDG-dependent framing, not "depth N"', () => {
const out = formatImpactResult(pdgFindings());
// PDG framing — NOT the callgraph "depth N / WILL BREAK (direct)" labels.
expect(out).toContain('PDG-dependent symbols');
expect(out).not.toMatch(/d=\d/);
expect(out).not.toContain('WILL BREAK (direct)');
expect(out).not.toContain('LIKELY AFFECTED');
expect(out).not.toContain('MAY NEED TESTING');
// The callgraph "Blast radius for ... will break if changed" headline must
// not leak into PDG output.
expect(out).not.toContain('Blast radius for');
// The affected symbols are listed.
expect(out).toContain('applyDiscount');
expect(out).toContain('finalizeTotal');
expect(out).toContain('src/svc.ts');
// The intra-procedural caveat is present (cross-function not modeled).
expect(out.toLowerCase()).toContain('cross-function');
expect(out.toLowerCase()).toContain('not modeled');
// The callgraph DI / dynamic-dispatch lower-bound copy must NEVER appear.
expect(out).not.toContain('dynamic dispatch');
expect(out).not.toContain('binding via DI');
});
it('surfaces ambiguous-projection and unresolved block counts honestly', () => {
const out = formatImpactResult(
pdgFindings({
ambiguousProjectionCount: 2,
unresolvedBlockCount: 1,
byDepth: {
1: [
{
depth: 1,
id: 'Function:src/svc.ts:applyDiscount',
name: 'applyDiscount',
type: 'Function',
filePath: 'src/svc.ts',
ambiguous: true,
processes: [],
},
{
depth: 1,
id: null,
name: '(top-level)',
type: 'BasicBlock',
filePath: 'src/svc.ts',
unresolved: true,
processes: [],
},
],
},
byDepthCounts: { 1: 2 },
}),
);
expect(out).toContain('2 block(s) could not be attributed');
expect(out).toContain('1 dependence block(s) map to no owning');
// The shadow / ambiguous rows carry their flags inline.
expect(out).toContain('[ambiguous]');
expect(out).toContain('[unresolved]');
});
it('flags truncation honestly', () => {
const out = formatImpactResult(pdgFindings({ truncated: true, truncatedBy: 'depth' }));
expect(out).toContain('Truncated');
expect(out).toContain('by depth');
expect(out).toContain('deeper PDG impacts may exist');
});
it('renders the degradation note as remediation, not a zero/empty blast radius', () => {
// Shaped like the `_impactImpl` pdgLayer-degradation early return.
const out = formatImpactResult({
mode: 'pdg',
pdgLayer: 'no-layer',
note: "No PDG layer in this index. Run `gitnexus analyze --pdg` to build it.",
target: { name: 'computeTotal' },
direction: 'downstream',
impactedCount: 0,
risk: 'UNKNOWN',
});
// The remediation guidance is present.
expect(out).toContain('analyze --pdg');
expect(out).toContain('no usable PDG layer');
// It must NOT read as a confident "isolated / no dependencies / safe".
expect(out).not.toContain('isolated');
expect(out).not.toContain('No downstream dependencies found');
});
it('names the missing sub-layer in a partial-degradation note', () => {
const out = formatImpactResult({
mode: 'pdg',
pdgLayer: 'sub-layer-missing',
missingSubLayer: 'REACHING_DEF',
note: 'CDG present but REACHING_DEF absent.',
target: { name: 'computeTotal' },
direction: 'downstream',
impactedCount: 0,
risk: 'UNKNOWN',
});
expect(out).toContain('REACHING_DEF');
expect(out).toContain('analyze --pdg');
expect(out).not.toContain('isolated');
});
it('renders the no-body (KTD6) caveat, not "isolated / no dependencies"', () => {
// Shaped like `_runImpactPDG`'s no-body early return.
const out = formatImpactResult({
mode: 'pdg',
target: { id: 'Interface:src/types.ts:Card', name: 'Card', type: 'Interface', filePath: 'src/types.ts' },
direction: 'downstream',
reachableBlocks: [],
blockCount: 0,
truncated: false,
depthReached: 0,
epistemic: 'no-pdg-body',
note:
"'Card' has no PDG body — no BasicBlocks / control- or data-dependence edges exist for " +
'this symbol (e.g. an interface, type alias, abstract/ambient member, or a one-line ' +
'declaration with no CFG). This is NOT a confident "no impact": the intra-procedural PDG ' +
"mode cannot model this symbol kind. Use mode:'callgraph' for its inter-procedural blast radius.",
impactedCount: 0,
risk: 'UNKNOWN',
byDepth: {},
byDepthCounts: { 1: 0 },
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
});
expect(out).toContain('no PDG body');
expect(out.toLowerCase()).toContain('not applicable');
// NOT the false-safe callgraph "isolated" headline. (The note may DISCLAIM
// "no impact", but the confident standalone "appears isolated." sentence
// and the callgraph "No ... dependencies found." headline must be absent.)
expect(out).not.toContain('appears isolated');
expect(out).not.toContain('No downstream dependencies found');
});
it('renders has-body-but-no-dependence as not-isolated, with the cross-function caveat', () => {
// `_runImpactPDG` reachableBlocks.length === 0 path (body exists, no edges).
const out = formatImpactResult({
mode: 'pdg',
target: { id: 'Function:src/svc.ts:noop', name: 'noop', type: 'Function', filePath: 'src/svc.ts' },
direction: 'downstream',
impactedCount: 0,
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
note:
"'noop' has a PDG body but no intra-procedural downstream dependence edges " +
'(no CDG/REACHING_DEF reachability from its blocks). This is distinct from "no PDG body". ' +
"Use mode:'callgraph' for its inter-procedural blast radius.",
reachableBlocks: [],
blockCount: 0,
depthReached: 1,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
byDepth: {},
byDepthCounts: { 1: 0 },
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
});
expect(out).toContain('no intra-procedural PDG-dependent symbols');
// The caveat may reference the word "isolated" to disclaim it, but the
// confident callgraph "appears isolated." headline must be absent.
expect(out).not.toContain('appears isolated');
expect(out).not.toContain('No downstream dependencies found');
expect(out.toLowerCase()).toContain('cross-function');
});
});
describe('formatImpactResult — callgraph rendering is UNCHANGED (regression guard)', () => {
// A known callgraph result. The exact rendered string is pinned: U5 must not
// perturb the default-mode output by one byte (KTD8 interchangeability).
const callgraphResult = {
target: { kind: 'Function', name: 'computeTotal' },
direction: 'upstream',
impactedCount: 2,
risk: 'MEDIUM',
byDepthCounts: { 1: 1, 2: 1 },
byDepth: {
1: [
{
type: 'Function',
name: 'callerA',
filePath: 'src/a.ts',
relationType: 'CALLS',
confidence: 1,
},
],
2: [
{
type: 'Function',
name: 'callerB',
filePath: 'src/b.ts',
relationType: 'CALLS',
confidence: 0.8,
},
],
},
};
it('renders the callgraph result with the exact pre-U5 text (byte-identical)', () => {
const expected = [
'Blast radius for Function computeTotal (upstream): 2 symbol(s) depends on this (will break if changed)',
'',
'd=1: WILL BREAK (direct) (1)',
' Function callerA → src/a.ts [CALLS]',
'',
'd=2: LIKELY AFFECTED (indirect) (1)',
' Function callerB → src/b.ts [CALLS] (conf: 0.8)',
].join('\n');
expect(formatImpactResult(callgraphResult)).toBe(expected);
});
it('does not apply any PDG framing to a callgraph result', () => {
const out = formatImpactResult(callgraphResult);
expect(out).not.toContain('PDG-dependent symbols');
expect(out).not.toContain('intra-procedural');
expect(out).not.toContain('analyze --pdg');
});
it('renders the callgraph summary-only branch unchanged', () => {
const out = formatImpactResult({
target: { kind: 'Function', name: 'foo' },
direction: 'downstream',
impactedCount: 3,
risk: 'LOW',
byDepthCounts: { 1: 2, 2: 1 },
// no byDepth → summary-only branch
});
expect(out).toContain('(summary only — use summaryOnly: false to see symbol lists)');
expect(out).toContain('d=1: WILL BREAK (direct) (2)');
expect(out).not.toContain('PDG-dependent symbols');
});
it('renders the callgraph isolated / zero case unchanged', () => {
const out = formatImpactResult({
target: { name: 'lonely' },
direction: 'downstream',
impactedCount: 0,
risk: 'LOW',
});
expect(out).toBe('lonely: No downstream dependencies found. This symbol appears isolated.');
});
it('renders the callgraph lower-bound (DI/dynamic-dispatch) copy unchanged', () => {
const out = formatImpactResult({
target: { name: 'viaInterface' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
epistemic: 'lower-bound',
boundaries: ['interface PaymentGateway'],
});
expect(out).toContain('LOWER BOUND');
expect(out).toContain('interface PaymentGateway');
expect(out).not.toContain('PDG');
});
});