From f63f3ebeac81f1a9d7ebef9f1caf3ce61ccd7bcc Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 16 Jun 2026 08:11:24 +0000 Subject: [PATCH] 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 --- gitnexus/src/cli/ai-context.ts | 6 +- gitnexus/src/cli/eval-server.ts | 122 +++++++ .../test/unit/cli-impact-pdg-format.test.ts | 333 ++++++++++++++++++ 3 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/unit/cli-impact-pdg-format.test.ts diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 8cbfe2b6d..b1a66ffdf 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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. diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index a3a058264..d1cb77647 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -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; diff --git a/gitnexus/test/unit/cli-impact-pdg-format.test.ts b/gitnexus/test/unit/cli-impact-pdg-format.test.ts new file mode 100644 index 000000000..d7d42f8c7 --- /dev/null +++ b/gitnexus/test/unit/cli-impact-pdg-format.test.ts @@ -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 = {}): Record { + 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'); + }); +});