From a9fd55a91b4a399dadfdba7ec7285fc166ef9543 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 18 Jun 2026 08:51:19 +0000 Subject: [PATCH] feat(impact): unify pdg symbol reach --- gitnexus/bench/impact-pdg/README.md | 76 +++---- gitnexus/bench/impact-pdg/measure.mjs | 48 ++--- gitnexus/bench/impact-pdg/metrics.mjs | 24 ++- gitnexus/src/cli/ai-context.ts | 2 +- gitnexus/src/cli/eval-server.ts | 108 +++++----- gitnexus/src/mcp/local/local-backend.ts | 190 +++++++++++++++--- gitnexus/src/mcp/local/pdg-impact.ts | 32 ++- gitnexus/src/mcp/tools.ts | 10 +- .../test/integration/impact-pdg-shape.test.ts | 23 ++- gitnexus/test/unit/ai-context.test.ts | 1 + gitnexus/test/unit/calltool-dispatch.test.ts | 63 ++++-- .../test/unit/cli-impact-pdg-format.test.ts | 80 +++++--- .../test/unit/impact-pdg-metric-math.test.ts | 28 +++ 13 files changed, 471 insertions(+), 214 deletions(-) diff --git a/gitnexus/bench/impact-pdg/README.md b/gitnexus/bench/impact-pdg/README.md index ef66bbd47..135e717da 100644 --- a/gitnexus/bench/impact-pdg/README.md +++ b/gitnexus/bench/impact-pdg/README.md @@ -9,11 +9,11 @@ > — prints a stratified P/R/F1 table + a plain-language decision recommendation, > and gates regressions with `--check`. It now also prints an additive **unified > impact axes** table that keeps line-level and symbol-level truth separate while -> comparing current `callgraph`, current `pdg`, and the evaluation-only -> `composed-current` baseline. The measured native result remains: **PDG is exact -> at intra-procedural statement granularity; call-graph is exact at -> inter-procedural symbol granularity; the two answer different questions and -> neither dominates.** +> comparing `callgraph`, unified `pdg`, and the evaluation-only +> `composed-current` control baseline. The measured native result remains: +> **PDG is exact at intra-procedural statement granularity; call-graph remains +> the comparator for inter-procedural symbol granularity; unified PDG must match +> that composed baseline before any default-switch decision.** ## What this measures @@ -23,12 +23,13 @@ granularities**: - `mode: 'callgraph'` (the default) — inter-procedural BFS over symbol→symbol edges. It answers *"what other symbols depend on / are called by this one?"* at **symbol granularity**, scored against `inter_AIS`. -- `mode: 'pdg'` (opt-in) — a **statement-anchored** intra-procedural dependence - slice from the persisted CDG + REACHING_DEF Program Dependence Graph. Seeded - with `line: N` (`impact({mode:'pdg', line:N})`), it returns +- `mode: 'pdg'` (opt-in) — the unified PDG-facing result. Its local + statement slice comes from the persisted CDG + REACHING_DEF Program Dependence + Graph. Seeded with `line: N` (`impact({mode:'pdg', line:N})`), it returns `affectedStatements: {line, filePath, text}[]` — the dependent **statements** of - the changed line N. It answers *"which statements inside this function does - changing line N affect?"* at **line granularity**, scored against `intra_AIS`. + the changed line N — and also attaches inter-procedural symbol reach in + `interproceduralByDepth`/`byDepth` for the same target. The native PDG row is still scored against + `intra_AIS`; the unified axes score its statement and symbol outputs together. They measure **different scopes**, so the harness scores each at its native granularity against its native ground truth and reports both side by side. The @@ -39,8 +40,7 @@ neither strictly dominates*. ## Unified impact axes The harness also reports a separate unified comparison that is designed for the -next architecture question: *could a future PDG-only / SDG-like impact engine -replace the composition of today's engines?* This report is additive. It does not +current architecture question: *does unified `mode:'pdg'` match the composition of today's engines?* This report is additive. It does not replace the native table above, and it does not change `baselines.json` gating. Unified AIS has two namespaces: @@ -51,17 +51,17 @@ Unified AIS has two namespaces: Each engine is adapted onto those axes without lossy projection: - `callgraph` contributes only the `symbol` axis. -- `pdg` contributes only the `statement` axis. -- `composed-current` is an evaluation-only control row that unions current - callgraph symbols with current PDG statements. +- `pdg` contributes the `statement` axis from `affectedStatements` and the + `symbol` axis from its unified `interproceduralByDepth`/`byDepth` inter-procedural reach. +- `composed-current` remains an evaluation-only control row that unions standalone + callgraph symbols with PDG statements. -The report intentionally has no single blended unified F1. A future -`pdg-interproc` or SDG candidate must be judged axis-by-axis against -`composed-current` so line precision cannot hide inter-symbol misses, and -symbol recall cannot hide statement-level blindness. The control row is a recall -baseline, not a perfection claim: current PDG can still contribute intra-line -noise on pure-inter fixtures, so a future SDG candidate should match or exceed -recall while reducing or bounding FPIS. +The report intentionally has no single blended unified F1. `pdg` is now judged +axis-by-axis against `composed-current` so line precision cannot hide +inter-symbol misses, and symbol recall cannot hide statement-level blindness. The +control row is a recall baseline, not a perfection claim: PDG can still +contribute intra-line noise on pure-inter fixtures, so default-switch decisions +should require matching recall while reducing or bounding FPIS. > **A note on `line`.** A whole-symbol PDG slice (no `line`) is empty by design: > intra-procedural dependence stays inside the function, so every reachable block @@ -75,11 +75,10 @@ recall while reducing or bounding FPIS. `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. +`affectedStatementCount`, and callgraph-compatible parity fields (`byDepth`, +`byDepthCounts`, `summary`, `affected_processes`, `affected_modules`). +`affectedStatements` is the statement-level PDG slice; `interproceduralByDepth` is the explicit cross-function reach; `byDepth` remains the +compatibility symbol bucket attached by unified PDG mode. Degraded PDG results are explicit, not empty successes. `no-layer`, `sub-layer-missing`, and `unknown` responses keep `mode:'pdg'`, target metadata @@ -206,10 +205,10 @@ call-graph row is symbol-vs-`inter_AIS`: - On a **mixed** fixture, both rows are real: PDG resolves the intra statement set, call-graph reaches the callee(s). -**PDG cannot cross a function boundary; call-graph cannot see below function -granularity.** Neither is a refinement of the other — they compose. A full -mixed-locus blast radius is the *union* of call-graph's inter-symbol reach and -PDG's intra-statement slice. +**The native rows still measure different units.** The PDG native row scores +statement reach, while the callgraph native row scores symbol reach. The unified +axes table is where `pdg` is judged as the composed result: statement reach in +`affectedStatements`, inter-symbol reach in `byDepth`. ## Substrate (the load-bearing mechanism — R8) @@ -308,9 +307,9 @@ Read it honestly: pure-inter router has an empty `intra_AIS`, and the line-seeded slice returns the router's *own* control-dependent routing returns — FPIS against the empty truth (precision 0, recall `n/a`). These are **symmetric**: each engine is blind to - the other's scope. PDG cannot cross a call boundary; call-graph cannot see below - a function. The per-case lines surface each slice (`pdg line/intra: …`) and each - callee set (`cg symbol/inter: …`) so this is visible, not hidden. + the other's native scope. The per-case lines surface each statement slice (`pdg + line/intra: …`) and each callee set (`cg symbol/inter: …`), while the unified + table verifies whether `pdg` now carries both axes. ## Decision recommendation (the verdict — F2) @@ -329,10 +328,11 @@ Read it honestly: > (intra & mixed PDG F1 = 1.0, FPIS = FNIS = 0). This is a question call-graph > **cannot answer at all** (it has no notion of a statement). > -> They **compose**: a full mixed-locus blast radius is the *union* of -> call-graph's inter-symbol reach and PDG's intra-statement slice. The unified -> axes table makes that composition explicit through the `composed-current` row, -> which is the recall baseline a future SDG / `pdg-interproc` candidate must +> `mode:'pdg'` now composes those surfaces in one result: `affectedStatements` +> carries statement-level dependence and `interproceduralByDepth`/`byDepth` carries +> inter-procedural symbols. `mode:'callgraph'` remains the option-driven comparator/default. The +> unified axes table keeps `composed-current` as the control baseline that PDG +> must match or beat before any default-switch decision. > match or exceed while reducing or bounding FPIS. Reach for the line-seeded > PDG when you need statement-level dependence *inside* a function; reach for > call-graph when you need diff --git a/gitnexus/bench/impact-pdg/measure.mjs b/gitnexus/bench/impact-pdg/measure.mjs index cfe2e8784..ffa316477 100644 --- a/gitnexus/bench/impact-pdg/measure.mjs +++ b/gitnexus/bench/impact-pdg/measure.mjs @@ -209,14 +209,19 @@ function callgraphCisFromResult(res) { * U7-rework CIS: the dependent STATEMENTS the change at `criterion.line` reaches. */ function pdgCisFromResult(res) { + const inter = callgraphCisFromResult({ + byDepth: res?.interproceduralByDepth ?? res?.pdgInterprocedural?.byDepth ?? {}, + }); return { - keys: pdgLineCis(res?.affectedStatements), + lineKeys: pdgLineCis(res?.affectedStatements), + symbolKeys: inter.keys, meta: { affectedStatementCount: res?.affectedStatementCount ?? 0, blockCount: res?.blockCount ?? null, criterionLine: res?.criterionLine ?? null, epistemic: res?.epistemic ?? null, note: res?.note ?? null, + interprocedural: res?.pdgInterprocedural ?? null, }, }; } @@ -429,10 +434,9 @@ function decisionRecommendation(strata, unified, underpowered, exclusions) { // Inter-scope: the cross-function blast radius — the question call-graph answers. lines.push( `On INTER-scope (symbol granularity), call-graph scores R=${fmt(cgInterR)} F1=${fmt(cgInterF1)} ` + - `against inter_AIS: it recovers the cross-function callees exactly. PDG mode is ` + - `intra-procedural, so on a pure-inter fixture it returns only the router's own ` + - `control-dependent statements (FPIS against the empty intra_AIS — recall n/a). Call-graph is ` + - `the engine for "what else calls/uses this?".`, + `against inter_AIS: it recovers the cross-function callees exactly. Unified PDG mode now ` + + `attaches the same inter-symbol reach in interproceduralByDepth/byDepth while keeping statement reach in ` + + `affectedStatements, so the symbol axis can be compared directly against callgraph.`, ); // Mixed-scope: both engines contribute, each in its own scope. @@ -446,25 +450,22 @@ function decisionRecommendation(strata, unified, underpowered, exclusions) { if (unified) { lines.push( - `Unified-axis check: current callgraph leaves the intra-line axis empty, and current PDG leaves ` + - `the inter-symbol axis empty. The evaluation-only composed-current baseline combines both current ` + - `outputs and reaches min defined recall=${fmt(unified['composed-current'].minRecall)} with ` + - `FPIS=${unified['composed-current'].fpis} and FNIS=${unified['composed-current'].fnis}. ` + - `A future PDG-only/SDG candidate must match or exceed that recall while reducing or ` + - `bounding FPIS before any default switch.`, + `Unified-axis check: current callgraph leaves the intra-line axis empty. Unified PDG now ` + + `covers both axes, while composed-current remains the control baseline that combines the ` + + `standalone callgraph symbol reach with PDG statement reach. composed-current reaches min ` + + `defined recall=${fmt(unified['composed-current'].minRecall)} with ` + + `FPIS=${unified['composed-current'].fpis} and FNIS=${unified['composed-current'].fnis}; ` + + `pdg should match that recall before any default-switch discussion.`, ); } lines.push( - `VERDICT: the two engines answer DIFFERENT questions at DIFFERENT granularities, and NEITHER ` + - `dominates. mode:'callgraph' (the default) is the correct engine for the inter-procedural ` + - `safety question — "what else depends on / calls this symbol?" — carrying the cross-function ` + - `reach the blast radius needs. mode:'pdg' (opt-in, seeded with line:N, where analyze --pdg ` + - `persisted the layer) is PRECISE at intra-procedural STATEMENT granularity — "which statements ` + - `inside this function does changing line N affect?" — a question call-graph cannot answer at ` + - `all. Use call-graph for cross-symbol impact; reach for line-seeded PDG when you need ` + - `statement-level dependence INSIDE a function. They compose: a full mixed-locus blast radius ` + - `is the UNION of call-graph's inter-symbol reach and PDG's intra-statement slice.`, + `VERDICT: keep option-driven comparison, but mode:'pdg' is now the unified PDG-facing ` + + `answer: statement-level affectedStatements come from the persisted CDG/REACHING_DEF slice, ` + + `and inter-procedural symbol reach is carried in interproceduralByDepth/byDepth. mode:'callgraph' remains the ` + + `default/comparator for the established symbol-only traversal. The accuracy decision should be ` + + `made from the unified axes: pdg must preserve statement recall while matching the composed ` + + `inter-symbol baseline and bounding FPIS.`, ); if (exclusions.length > 0) { lines.push( @@ -546,11 +547,11 @@ async function run() { const cg = callgraphCisFromResult(results.callgraph); const pdg = pdgCisFromResult(results.pdg); const cgScore = scoreCallgraph(fx.gt, cg.keys); // symbol/inter - const pdgScore = scorePdg(fx.gt, pdg.keys); // line/intra + const pdgScore = scorePdg(fx.gt, pdg.lineKeys); // line/intra const unifiedTruth = unifiedAis(fx.gt); const cgUnified = callgraphUnifiedCis(fx.gt, cg.keys); - const pdgUnified = pdgUnifiedCis(pdg.keys); + const pdgUnified = pdgUnifiedCis(pdg.lineKeys, pdg.symbolKeys, fx.gt); const composedUnified = composeUnifiedCis(cgUnified, pdgUnified); const unifiedScores = { callgraph: scoreUnifiedAxes(cgUnified, unifiedTruth), @@ -584,7 +585,8 @@ async function run() { affectedStatementCount: pdg.meta.affectedStatementCount, blockCount: pdg.meta.blockCount, criterionLine: pdg.meta.criterionLine, - lines: [...pdg.keys].sort(), + lines: [...pdg.lineKeys].sort(), + symbols: [...pdg.symbolKeys].sort(), score: pdgScore, // vs intra_AIS (line) }, unified: unifiedScores, diff --git a/gitnexus/bench/impact-pdg/metrics.mjs b/gitnexus/bench/impact-pdg/metrics.mjs index 0608d99d5..64c974dc2 100644 --- a/gitnexus/bench/impact-pdg/metrics.mjs +++ b/gitnexus/bench/impact-pdg/metrics.mjs @@ -31,10 +31,10 @@ * `inter_AIS` symbol set (via `aisByScope`). This is the unit at which the * call-graph blast radius is meaningful. * - * Neither is a strict refinement of the other: PDG resolves the dependent - * statements WITHIN a function but cannot cross a call boundary; call-graph - * resolves the cross-function symbol reach but cannot see below function - * granularity. The harness reports both, side by side, per locus stratum. + * Neither native row is a strict refinement of the other: the PDG native + * metric resolves dependent statements WITHIN a function, while call-graph + * resolves cross-function symbol reach. The unified axes report checks whether + * mode:'pdg' carries both outputs without blending their granularities. * * `partitionCisByScope`/`aisByScope` (symbol-level) remain for the call-graph * path; `pdgLineCis`/`intraLineAis` (line-level) drive the PDG path. @@ -284,9 +284,19 @@ export function callgraphUnifiedCis(gt, symbolCisKeys) { return { intraLine: new Set(), interSymbol: tagSymbolKeys(inter) }; } -/** Current PDG unified CIS: intra-line axis only. */ -export function pdgUnifiedCis(lineCisKeys) { - return { intraLine: tagLineKeys(lineCisKeys), interSymbol: new Set() }; +/** + * Unified PDG CIS: statement axis from affectedStatements plus, once runtime + * mode:'pdg' composes interprocedural reach, symbol axis from byDepth. The + * criterion symbol is filtered because inter_AIS is cross-function by + * construction. Passing only lineCisKeys preserves the old intra-only shape for + * focused metric tests. + */ +export function pdgUnifiedCis(lineCisKeys, symbolCisKeys = new Set(), gt = null) { + const { criterionKey } = gt ? aisByScope(gt) : { criterionKey: null }; + const inter = criterionKey + ? new Set([...symbolCisKeys].filter((k) => k !== criterionKey)) + : new Set(symbolCisKeys); + return { intraLine: tagLineKeys(lineCisKeys), interSymbol: tagSymbolKeys(inter) }; } /** Evaluation-only composed baseline: callgraph inter-symbol + PDG intra-line. */ diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 3060fc381..299b4b7a3 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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"\` with \`line: \` — 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).` + ? ` For unified PDG impact, add \`mode: "pdg"\` with optional \`line: \` — it returns statement-level \`affectedStatements\` over CDG + REACHING_DEF and inter-procedural symbols in \`interproceduralByDepth\`/\`byDepth\`; 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))}})\`. diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index b7be1bb08..bb1e66950 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -267,6 +267,49 @@ export function formatImpactResult(result: any): string { // reach here. if (result.mode === 'pdg') { const name = target?.name || '?'; + const appendPdgInterproceduralSymbols = (lines: string[]): boolean => { + const byDepth = + result.interproceduralByDepth || result.pdgInterprocedural?.byDepth || result.byDepth || {}; + const byDepthCounts = + result.interproceduralByDepthCounts || + result.pdgInterprocedural?.byDepthCounts || + result.byDepthCounts || + {}; + const depthKeys = Array.from( + new Set([...Object.keys(byDepthCounts), ...Object.keys(byDepth)]), + ) + .map((d) => Number(d)) + .filter((d) => Number.isFinite(d)) + .sort((a, b) => a - b); + const hasReach = depthKeys.some((depth) => { + const items = byDepth[depth] || byDepth[String(depth)] || []; + const count = byDepthCounts[depth] ?? byDepthCounts[String(depth)] ?? items.length; + return count > 0; + }); + if (!hasReach) return false; + + const totalSymbols = + result.pdgInterprocedural?.impactedCount ?? + (typeof result.impactedCount === 'number' ? result.impactedCount : 0); + lines.push(''); + lines.push(`Inter-procedural symbol reach (${totalSymbols}):`); + for (const depth of depthKeys) { + const items = byDepth[depth] || byDepth[String(depth)] || []; + const count = byDepthCounts[depth] ?? byDepthCounts[String(depth)] ?? items.length; + if (count <= 0) continue; + lines.push(` d=${depth} (${count})`); + 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(', ')}]` : ''; + lines.push(` ${item.type || ''} ${item.name} → ${item.filePath}${flagStr}`); + } + if (count > shown) lines.push(` ... and ${count - shown} more`); + } + return true; + }; // (1) Degradation — the PDG layer (or a sub-layer) is absent/unreadable. // `pdgLayer` is the non-'ready' state from `pdgLayerStatus`. Print the @@ -288,14 +331,15 @@ export function formatImpactResult(result: any): string { // 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 noBodyLines = [ + `${name}: local PDG slice 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".`, + ]; + appendPdgInterproceduralSymbols(noBodyLines); + if (result.note) noBodyLines.push(result.note); + return noBodyLines.join('\n'); } // (2b) STATEMENT-ANCHORED SLICE (mode:'pdg' + line). When `criterionLine` is @@ -333,6 +377,7 @@ export function formatImpactResult(result: any): string { `⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`, ); } + appendPdgInterproceduralSymbols(emptySliceLines); if (result.note) emptySliceLines.push(result.note); return emptySliceLines.join('\n'); } @@ -353,6 +398,7 @@ export function formatImpactResult(result: any): string { `⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`, ); } + appendPdgInterproceduralSymbols(slLines); if (result.note) { slLines.push(''); slLines.push(`ℹ️ ${result.note}`); @@ -360,57 +406,25 @@ export function formatImpactResult(result: any): string { return slLines.join('\n').trim(); } - 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) { + if (!appendPdgInterproceduralSymbols(pdgLines)) { 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.`, + `${name} (${direction}): no inter-procedural symbols reached. ` + + `The local PDG statement slice may still report affectedStatements when seeded with line:.`, ); - } 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. + // The assembled note carries the local-PDG framing plus the unified + // inter-procedural symbol-reach contract; 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.', + 'ℹ️ Program Dependence Graph result — statement reach is reported in affectedStatements and inter-procedural symbol reach in interproceduralByDepth/byDepth.', ); } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 0a52d5e79..d617676e2 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -4323,25 +4323,18 @@ export class LocalBackend { } if (mode === 'pdg') { - // KTD12 — param-compatibility hard rejections (decided as errors, NOT - // silent ignores and NOT an `ignoredParams` echo). Each names a symbol- - // graph / cross-repo concept the PDG engine cannot honor: - // relationTypes → names symbol edges (PDG walks BasicBlock edges). - // crossDepth → cross-repo hops (PDG is single-repo intra-procedural). - // minConfidence → CDG/RD edges may carry no confidence → would drop all. - // A loud failure beats a quietly-wrong result. (@group targets are - // rejected at the group-forward boundary in callToolAtGroupRepo before - // they ever reach here; see KTD12.) + // PDG mode is now unified inside a single repo: it combines the local + // CDG/RD statement slice with the same inter-symbol reach used for the + // option-driven comparison path. Cross-repo fan-out remains a callgraph + // feature, so crossDepth is still a loud error rather than a silent ignore. const incompatible: string[] = []; - if (params.relationTypes !== undefined) incompatible.push('relationTypes'); if (params.crossDepth !== undefined) incompatible.push('crossDepth'); - if (params.minConfidence !== undefined) incompatible.push('minConfidence'); if (incompatible.length > 0) { return makePdgImpactErrorResult({ mode: 'pdg', error: `Parameter(s) ${incompatible.join(', ')} are not supported with mode:'pdg' ` + - `(intra-procedural, single-repo, dependence-edge based). Remove them or use mode:'callgraph'.`, + `(single-repo PDG impact). Remove them or use mode:'callgraph' for cross-repo fan-out.`, target: { name: target }, direction, }); @@ -4612,11 +4605,16 @@ export class LocalBackend { } } + const effectiveRelationTypes = + (symType === 'Class' || symType === 'Interface') && + !hasExplicitRelationTypes && + !relationTypes.includes('ACCESSES') + ? [...relationTypes, 'ACCESSES'] + : relationTypes; + // (4) single → route the resolved symbol to the engine selected by `mode`. - // The PDG engine does NOT touch `_runImpactBFS`, so a `pdg` call never runs - // callgraph. if (mode === 'pdg') { - return this._runImpactPDG({ + const pdgResult = await this._runImpactPDG({ repo, sym, symType, @@ -4629,14 +4627,22 @@ export class LocalBackend { // lifecycle; `pdg-impact.ts` owns traversal/projection. executeParameterized, }); - } - const effectiveRelationTypes = - (symType === 'Class' || symType === 'Interface') && - !hasExplicitRelationTypes && - !relationTypes.includes('ACCESSES') - ? [...relationTypes, 'ACCESSES'] - : relationTypes; + try { + const interproceduralResult = await this._runImpactBFS(repo, sym, symType, direction, { + maxDepth, + relationTypes: effectiveRelationTypes, + includeTests, + minConfidence, + limit: Number.isFinite(params.limit) ? params.limit : 100, + offset: Number.isFinite(params.offset) ? params.offset : 0, + }); + return this.composeUnifiedPdgImpactResult(pdgResult, interproceduralResult); + } catch (e) { + logQueryError('impact:pdg-interprocedural-reach', e); + return this.composeUnifiedPdgImpactResult(pdgResult, null, e); + } + } return this._runImpactBFS(repo, sym, symType, direction, { maxDepth, @@ -4653,9 +4659,10 @@ export class LocalBackend { * Delegates the PDG impact engine to `pdg-impact.ts`. * * The private method remains as the LocalBackend dispatch seam so existing - * tests can keep asserting that `mode:'pdg'` routes here and never falls back - * to `_runImpactBFS`. The traversal/projection/result assembly lives in the - * extracted helper module. + * tests can keep asserting that `mode:'pdg'` routes through the PDG + * statement engine before LocalBackend attaches interprocedural symbol reach. + * The traversal/projection/result assembly lives in the extracted helper + * module. */ private async _runImpactPDG(deps: { repo: RepoHandle; @@ -4670,6 +4677,139 @@ export class LocalBackend { return runImpactPDG(deps); } + /** + * Compose `mode:'pdg'` into one user-facing impact result: + * + * - `affectedStatements` / `reachableBlocks` stay owned by the persisted PDG + * layer (CDG + REACHING_DEF), preserving the statement-level intra result. + * - `interproceduralByDepth` / `pdgInterprocedural` expose the symbol reach; + * `byDepth` stays as the compatibility symbol bucket so existing consumers + * still see one PDG result shape. + * + * The callgraph option remains available as the comparator/default path; this + * helper only changes the `pdg` result contract from intra-only to unified. + */ + private composeUnifiedPdgImpactResult( + pdgResult: PdgImpactResult, + interproceduralResult: any | null, + interproceduralError?: unknown, + ): PdgImpactResult { + if ('error' in pdgResult || 'pdgLayer' in pdgResult) return pdgResult; + + const localByDepth = pdgResult.byDepth ?? {}; + const localByDepthCounts = pdgResult.byDepthCounts ?? {}; + const interproceduralByDepth = interproceduralResult?.byDepth ?? {}; + const interproceduralByDepthCounts = interproceduralResult?.byDepthCounts ?? {}; + const byDepth: Record = {}; + const byDepthCounts: Record = {}; + const depthKeys = Array.from( + new Set([ + ...Object.keys(localByDepth), + ...Object.keys(interproceduralByDepth), + ...Object.keys(localByDepthCounts), + ...Object.keys(interproceduralByDepthCounts), + ]), + ) + .map((d) => Number(d)) + .filter((d) => Number.isFinite(d)) + .sort((a, b) => a - b); + for (const depth of depthKeys) { + const localItems = localByDepth[depth] ?? localByDepth[String(depth)] ?? []; + const interItems = + interproceduralByDepth[depth] ?? interproceduralByDepth[String(depth)] ?? []; + const items = [...localItems, ...interItems]; + if (items.length > 0) byDepth[depth] = items; + const localCount = + localByDepthCounts[depth] ?? localByDepthCounts[String(depth)] ?? localItems.length; + const interCount = + interproceduralByDepthCounts[depth] ?? + interproceduralByDepthCounts[String(depth)] ?? + interItems.length; + const totalCount = localCount + interCount; + if (totalCount > 0) byDepthCounts[depth] = totalCount; + } + + if (Object.keys(byDepthCounts).length === 0) { + const localZero = localByDepthCounts[1] ?? localByDepthCounts['1']; + const interZero = interproceduralByDepthCounts[1] ?? interproceduralByDepthCounts['1']; + if (typeof localZero === 'number' || typeof interZero === 'number') { + byDepthCounts[1] = + (typeof localZero === 'number' ? localZero : 0) + + (typeof interZero === 'number' ? interZero : 0); + } + } + + const localImpactedCount = + typeof pdgResult.impactedCount === 'number' ? pdgResult.impactedCount : 0; + const interproceduralImpactedCount = + typeof interproceduralResult?.impactedCount === 'number' + ? interproceduralResult.impactedCount + : 0; + const impactedCount = localImpactedCount + interproceduralImpactedCount; + const summary = interproceduralResult?.summary + ? { + ...interproceduralResult.summary, + direct: (pdgResult.summary?.direct ?? 0) + (interproceduralResult.summary.direct ?? 0), + } + : { + direct: pdgResult.summary?.direct ?? localImpactedCount, + processes_affected: 0, + modules_affected: 0, + }; + const affectedProcesses = interproceduralResult?.affected_processes ?? []; + const affectedModules = interproceduralResult?.affected_modules ?? []; + const partial = Boolean(interproceduralResult?.partial || interproceduralError); + const errorMessage = + interproceduralError instanceof Error + ? interproceduralError.message + : interproceduralError + ? String(interproceduralError) + : undefined; + + const noteParts = [ + pdgResult.note, + `Inter-procedural symbol reach is included using the resolved symbol graph; ` + + `statement-level PDG reach remains in affectedStatements.`, + ]; + if (errorMessage) { + noteParts.push( + `Inter-procedural symbol reach failed (${errorMessage}); byDepth is therefore a lower bound.`, + ); + } else if (interproceduralResult?.epistemic === 'lower-bound') { + noteParts.push( + `The inter-procedural symbol reach is a lower bound because unresolved indirection was detected.`, + ); + } + + return { + ...pdgResult, + impactedCount, + note: noteParts.filter(Boolean).join(' '), + summary, + byDepthCounts, + interproceduralByDepth, + interproceduralByDepthCounts, + affected_processes: affectedProcesses, + affected_modules: affectedModules, + byDepth, + ...(partial ? { partial: true } : {}), + ...(interproceduralResult?.epistemic + ? { interproceduralEpistemic: interproceduralResult.epistemic } + : {}), + ...(interproceduralResult?.boundaries + ? { interproceduralBoundaries: interproceduralResult.boundaries } + : {}), + ...(errorMessage ? { interproceduralError: errorMessage } : {}), + pdgInterprocedural: { + engine: 'symbol-graph', + impactedCount: interproceduralImpactedCount, + byDepthCounts: interproceduralByDepthCounts, + byDepth: interproceduralByDepth, + partial, + }, + }; + } + /** * #1858 — epistemic lower-bound detection. * diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index 89919ac0b..3a94c8577 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -298,6 +298,14 @@ export interface PdgImpactParityFields { affected_modules: unknown[]; } +export interface PdgInterproceduralImpact { + engine: 'symbol-graph'; + impactedCount: number; + byDepthCounts: Record; + byDepth: Record; + partial: boolean; +} + export interface PdgImpactBaseResult extends PdgImpactParityFields { mode: 'pdg'; target: PdgImpactTarget; @@ -305,6 +313,13 @@ export interface PdgImpactBaseResult extends PdgImpactParityFields { impactedCount: number; risk: 'UNKNOWN'; note?: string; + partial?: boolean; + interproceduralByDepth?: Record; + interproceduralByDepthCounts?: Record; + interproceduralEpistemic?: string; + interproceduralBoundaries?: unknown[]; + interproceduralError?: string; + pdgInterprocedural?: PdgInterproceduralImpact; } export interface PdgImpactSuccessResult extends PdgImpactBaseResult { @@ -494,15 +509,15 @@ function assemblePdgImpactResult(input: { `mode:'pdg' — intra-procedural slice from line ${input.criterionLine} of ` + `'${target.name}'. ${affectedStatements.length} ` + `${affectedStatements.length === 1 ? 'statement is' : 'statements are'} ${direction}-` + - `dependent on it (over CDG + REACHING_DEF). Cross-function (inter-procedural) impact ` + - `is NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.`, + `dependent on it (over CDG + REACHING_DEF). Inter-procedural symbol reach ` + + `is attached by impact mode's unified PDG dispatcher in interproceduralByDepth/byDepth.`, ] : [ `mode:'pdg' — intra-procedural Program Dependence Graph. ${impactedCount} owning ` + `${impactedCount === 1 ? 'symbol' : 'symbols'} reached via ${reachableBlocks.length} ` + `dependence ${reachableBlocks.length === 1 ? 'block' : 'blocks'} ` + - `(${direction} over CDG + REACHING_DEF). Cross-function (inter-procedural) impact is ` + - `NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.`, + `(${direction} over CDG + REACHING_DEF). Inter-procedural symbol reach ` + + `is attached by impact mode's unified PDG dispatcher in interproceduralByDepth/byDepth.`, ]; if (ambiguousCount > 0) { noteParts.push( @@ -952,9 +967,8 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise to slice from a statement, or use mode:'callgraph' for the ` + - `inter-procedural blast radius.`, + `"no impact": the local PDG statement slice cannot model this symbol kind. ` + + `Inter-procedural symbol reach may still be attached by the unified impact dispatcher.`, impactedCount: 0, risk: 'UNKNOWN', // KTD8 parity fields so a consumer iterating byDepth / reading the @@ -1061,8 +1075,8 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise to slice from a specific statement ` + - `(what depends on the code at that line), or use mode:'callgraph' for the ` + - `inter-procedural blast radius.`, + `(what depends on the code at that line). Inter-procedural symbol reach is attached ` + + `separately by the unified impact dispatcher.`, reachableBlocks: [] as string[], blockCount: 0, affectedStatements: [], diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 7a79562bb..a4142aeb5 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -413,11 +413,11 @@ Each edit is tagged with confidence: description: `Analyze the blast radius of changing a code symbol. Returns affected symbols grouped by depth, plus risk assessment, affected execution flows, and affected modules. -MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/EXTENDS/IMPLEMENTS) — inter-procedural, the established behavior. "pdg" computes the blast radius from the persisted Program Dependence Graph (control + data dependence) — finer-grained WITHIN a function but intra-procedural, and requires an index built with \`gitnexus analyze --pdg\`. The two modes answer the same question with different engines; pdg is incompatible with relationTypes/crossDepth/minConfidence and with @group targets (each rejected). +MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/EXTENDS/IMPLEMENTS) — inter-procedural, the established comparator/default behavior. "pdg" requires an index built with \`gitnexus analyze --pdg\` and returns one unified PDG-facing result: statement-level control/data dependence from the persisted PDG plus inter-procedural symbol reach. The explicit interprocedural surface is interproceduralByDepth/pdgInterprocedural; byDepth remains the compatibility symbol bucket. pdg remains incompatible with crossDepth and @group targets; relationTypes/minConfidence filter the inter-symbol reach. -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. +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 in affectedStatements (line + text). Inter-procedural symbols are still reported through interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket. Without "line", pdg returns whole-symbol inter-procedural reach plus local whole-symbol PDG diagnostics. -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. +PDG OUTPUT CONTRACT: successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. 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. @@ -465,13 +465,13 @@ 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`. 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.", + "Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (established comparator). 'pdg' = unified PDG-facing impact: statement-level affectedStatements from the persisted control/data dependence layer plus inter-procedural symbols in interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket; requires `gitnexus analyze --pdg`. PDG is incompatible with crossDepth and @group targets; relationTypes/minConfidence filter the inter-symbol reach.", }, line: { type: 'integer', minimum: 1, description: - "1-based source line — PDG-only statement anchor (mode:'pdg'). Seeds the dependence slice on the statement at this line and returns what depends on it. Without it, a whole-symbol pdg slice is empty (intra-procedural reach stays inside the function).", + "1-based source line — PDG statement anchor (mode:'pdg'). Seeds affectedStatements on the statement at this line; inter-procedural symbols are still returned in interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket.", }, file_path: { type: 'string', diff --git a/gitnexus/test/integration/impact-pdg-shape.test.ts b/gitnexus/test/integration/impact-pdg-shape.test.ts index 8384be619..0458ab03d 100644 --- a/gitnexus/test/integration/impact-pdg-shape.test.ts +++ b/gitnexus/test/integration/impact-pdg-shape.test.ts @@ -288,12 +288,12 @@ withTestLbugDB( }); }); - // ── KTD5 ambiguous trap: pdg+ambiguous never runs the callgraph fan-out ─── - describe('KTD5 ambiguous target never invokes the callgraph BFS', () => { + // ── KTD5 ambiguous trap: pdg+ambiguous never runs interprocedural fan-out ── + describe('KTD5 ambiguous target never invokes the interprocedural BFS', () => { it("mode:'pdg' on an ambiguous target returns candidates, never calls _runImpactBFS", async () => { - // Spy on the private callgraph BFS; if the pdg ambiguous path leaked into - // the callgraph fan-out, this spy would fire (the silent-fallback KTD5 - // forbids). `dupTarget` collides across dupA/dupB by NAME. + // Spy on the private interprocedural BFS; ambiguous PDG has no single + // resolved symbol, so it must not run the composed symbol-reach pass. + // `dupTarget` collides across dupA/dupB by NAME. const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); try { const result = await backend.callTool('impact', { @@ -303,7 +303,7 @@ withTestLbugDB( }); expect(result.status).toBe('ambiguous'); expect(result.mode).toBe('pdg'); - // No callgraph engine ran under the pdg call. + // No interprocedural symbol-reach pass ran without a resolved target. expect(bfsSpy).not.toHaveBeenCalled(); // And it surfaces the candidate list (no silent zero blast radius). expect(Array.isArray(result.candidates)).toBe(true); @@ -321,8 +321,8 @@ withTestLbugDB( // (that would re-ambiguate a file_path/uid-disambiguated name, or anchor on a // DIFFERENT same-name symbol → wrong-symbol blast radius). With two functions // named `sameName` in different files, disambiguating by file_path/target_uid - // must (a) produce the CORRECT file's blast radius, and (b) NEVER fall into - // the callgraph `_runImpactBFS` fan-out. + // must produce the CORRECT local PDG blast radius before the composed + // interprocedural `_runImpactBFS` pass runs for the resolved symbol. describe('seed anchors on the resolved (disambiguated) symbol, not a name re-resolution', () => { it('file_path disambiguation reaches the right file’s downstream owner (not the other same-name fn)', async () => { const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); @@ -348,8 +348,9 @@ withTestLbugDB( ); expect(names.has('onlyB')).toBe(true); expect(names.has('onlyA')).toBe(false); - // KTD5: no callgraph engine ran under the pdg call. - expect(bfsSpy).not.toHaveBeenCalled(); + // Unified PDG now composes interprocedural symbol reach after the local + // PDG slice anchors on the resolved symbol. + expect(bfsSpy).toHaveBeenCalledTimes(1); } finally { bfsSpy.mockRestore(); } @@ -375,7 +376,7 @@ withTestLbugDB( ); expect(names.has('onlyA')).toBe(true); expect(names.has('onlyB')).toBe(false); - expect(bfsSpy).not.toHaveBeenCalled(); + expect(bfsSpy).toHaveBeenCalledTimes(1); } finally { bfsSpy.mockRestore(); } diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index f6bdbfbb0..c3eca3458 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -157,6 +157,7 @@ describe('generateAIContextFiles', () => { expect(withPdg).toContain('under what condition does X run'); expect(withPdg).toContain('line: '); expect(withPdg).toContain('affectedStatements'); + expect(withPdg).toContain('byDepth'); // 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); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 9bb64724e..2536fa3d6 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -1402,8 +1402,9 @@ 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 extracted traversal 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 plus interprocedural symbol +// reach, invalid modes hard-error, and the remaining incompatible params / +// @group targets are rejected. describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { let backend: LocalBackend; @@ -1461,7 +1462,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { expect(undef).toEqual(absent); }); - it("mode:'pdg' routes to the PDG traversal and NEVER runs the callgraph BFS (KTD5)", async () => { + it("mode:'pdg' routes to the PDG traversal and attaches interprocedural symbol reach", async () => { resolveSingleTarget(); const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); const result = await backend.callTool('impact', { @@ -1469,14 +1470,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { direction: 'upstream', mode: 'pdg', }); - // U3 landed — the call reaches the real `_runImpactPDG` traversal, not the - // old "not yet implemented" stub. It returns a pdg-shaped payload. + // The call reaches the real `_runImpactPDG` traversal, then composes the + // interprocedural symbol reach into the same pdg result. expect(result.error).toBeUndefined(); expect(result.mode).toBe('pdg'); expect(Array.isArray(result.reachableBlocks)).toBe(true); - // The load-bearing KTD5 invariant: the callgraph engine must NEVER be - // invoked under a pdg call (no silent fallback). - expect(bfsSpy).not.toHaveBeenCalled(); + expect(result.pdgInterprocedural).toBeDefined(); + expect(bfsSpy).toHaveBeenCalledTimes(1); }); it.each([['PDG'], ['pgd'], [''], [0], [null]])( @@ -1532,7 +1532,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { }, ); - it("mode:'pdg' + line:8 routes to the PDG traversal (no validation error, never the BFS)", async () => { + it("mode:'pdg' + line:8 routes to the PDG traversal and interprocedural reach", async () => { resolveSingleTarget(); const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG'); @@ -1546,30 +1546,53 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { expect(result.error).toBeUndefined(); expect(result.mode).toBe('pdg'); expect(pdgSpy).toHaveBeenCalledTimes(1); - // KTD5: the callgraph engine never runs under a pdg + line call. - expect(bfsSpy).not.toHaveBeenCalled(); + expect(bfsSpy).toHaveBeenCalledTimes(1); + expect(result.pdgInterprocedural).toBeDefined(); }); - it.each([ - ['relationTypes', { relationTypes: ['CALLS'] }], - ['crossDepth', { crossDepth: 2 }], - ['minConfidence', { minConfidence: 0.5 }], - ])("mode:'pdg' + %s → hard {error} (KTD12, not a silent ignore)", async (_label, extra) => { + it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => { resolveSingleTarget(); const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', mode: 'pdg', - ...extra, + crossDepth: 2, }); expect(result.error).toMatch(/not supported with mode:'pdg'/); - expect(result.error).toContain(_label); - // Hard error — never silently ignored, never the callgraph fan-out. + expect(result.error).toContain('crossDepth'); expect(bfsSpy).not.toHaveBeenCalled(); }); - it("ambiguous target under mode:'pdg' never invokes the callgraph fan-out (KTD5 ambiguous trap)", async () => { + it.each([ + ['relationTypes', { relationTypes: ['CALLS'] }, (opts: any) => opts.relationTypes], + ['minConfidence', { minConfidence: 0.5 }, (opts: any) => opts.minConfidence], + ])("mode:'pdg' + %s feeds the interprocedural symbol reach", async (_label, extra, readOpt) => { + resolveSingleTarget(); + const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ + target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + direction: 'upstream', + impactedCount: 0, + risk: 'LOW', + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + byDepthCounts: {}, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }); + const result = await backend.callTool('impact', { + target: 'main', + direction: 'upstream', + mode: 'pdg', + ...extra, + }); + expect(result.error).toBeUndefined(); + expect(result.mode).toBe('pdg'); + expect(bfsSpy).toHaveBeenCalledTimes(1); + expect(readOpt(bfsSpy.mock.calls[0][4])).toBeDefined(); + }); + + it("ambiguous target under mode:'pdg' never invokes interprocedural fan-out (KTD5 ambiguous trap)", async () => { // Two same-name Functions → resolver returns ambiguous. (executeParameterized as any).mockResolvedValue([ { diff --git a/gitnexus/test/unit/cli-impact-pdg-format.test.ts b/gitnexus/test/unit/cli-impact-pdg-format.test.ts index 828e8519f..aa8dd5ef5 100644 --- a/gitnexus/test/unit/cli-impact-pdg-format.test.ts +++ b/gitnexus/test/unit/cli-impact-pdg-format.test.ts @@ -2,9 +2,8 @@ * 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"); + * - inter-procedural symbol reach under a neutral heading, NOT callgraph severity labels; + * - the unified PDG caveat (statement reach in affectedStatements, symbol reach in interproceduralByDepth/byDepth); * - 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"; @@ -51,8 +50,8 @@ function pdgFindings(overrides: Record = {}): Record { expect(out).not.toContain('[0 upstream'); }); - it('renders findings under PDG-dependent framing, not "depth N"', () => { + it('renders unified PDG symbol reach without callgraph severity labels', () => { 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).toContain('Inter-procedural symbol reach'); + expect(out).toContain('d=1 (2)'); expect(out).not.toContain('WILL BREAK (direct)'); expect(out).not.toContain('LIKELY AFFECTED'); expect(out).not.toContain('MAY NEED TESTING'); @@ -124,9 +123,8 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { 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 unified contract is present. + expect(out).toContain('statement-level PDG reach remains in affectedStatements'); // The callgraph DI / dynamic-dispatch lower-bound copy must NEVER appear. expect(out).not.toContain('dynamic dispatch'); @@ -243,8 +241,8 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { 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.", + 'declaration with no CFG). This is NOT a confident "no impact": the local PDG ' + + 'statement slice cannot model this symbol kind. Inter-procedural symbol reach may still be attached.', impactedCount: 0, risk: 'UNKNOWN', byDepth: {}, @@ -284,8 +282,8 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { "'noop' has a PDG body but a WHOLE-SYMBOL downstream slice is empty: " + 'intra-procedural dependence stays inside the function, so every reachable block ' + 'is already part of the seed. Pass line: to slice from a specific statement ' + - "(what depends on the code at that line), or use mode:'callgraph' for the " + - 'inter-procedural blast radius.', + '(what depends on the code at that line). Inter-procedural symbol reach is attached ' + + 'separately by the unified impact dispatcher.', reachableBlocks: [], blockCount: 0, affectedStatements: [], @@ -299,7 +297,7 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { affected_processes: [], affected_modules: [], }); - expect(out).toContain('no intra-procedural PDG-dependent symbols'); + expect(out).toContain('no inter-procedural symbols reached'); // The new note steers to the statement-anchored mode. expect(out).toContain('WHOLE-SYMBOL'); expect(out).toMatch(/line:/); @@ -307,7 +305,7 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { // 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'); + expect(out).toContain('Inter-procedural symbol reach is attached'); }); // ── Statement-anchored (mode:'pdg' + line) rendering ────────────────────── @@ -329,23 +327,23 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { { line: 12, filePath: 'src/svc.ts', text: 'return sum;' }, ], affectedStatementCount: 2, - impactedCount: 1, + impactedCount: 0, risk: 'UNKNOWN', epistemic: 'pdg-intra-procedural', note: "mode:'pdg' — intra-procedural slice from line 8 of 'accum'. 2 statements are " + - 'downstream-dependent on it (over CDG + REACHING_DEF). Cross-function (inter-procedural) ' + - "impact is NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.", + 'downstream-dependent on it (over CDG + REACHING_DEF). Inter-procedural symbol reach ' + + 'is attached separately by the unified impact dispatcher.', reachableBlocks: ['b1', 'b2'], blockCount: 2, depthReached: 2, unresolvedBlockCount: 0, ambiguousProjectionCount: 0, - summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, - byDepthCounts: { 1: 1 }, + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + byDepthCounts: {}, affected_processes: [], affected_modules: [], - byDepth: { 1: [] }, + byDepth: {}, ...overrides, }; } @@ -357,11 +355,37 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { // Each dependent statement renders as ` L: `. expect(out).toContain(' L10: sum = sum + x;'); expect(out).toContain(' L12: return sum;'); - // It is the statement list — NOT the symbol-projection "PDG-dependent symbols" - // heading (that is the whole-symbol render path). - expect(out).not.toContain('PDG-dependent symbols'); - // The intra-procedural caveat note still surfaces. - expect(out.toLowerCase()).toContain('cross-function'); + // It is the statement list; no inter-symbol section appears without byDepth reach. + expect(out).not.toContain('Inter-procedural symbol reach ('); + // The unified PDG note still surfaces. + expect(out).toContain('Inter-procedural symbol reach is attached'); + }); + + it('renders statement slices with inter-procedural symbol reach when present', () => { + const out = formatImpactResult( + pdgStatementSlice({ + impactedCount: 1, + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + byDepthCounts: { 1: 1 }, + byDepth: { + 1: [ + { + depth: 1, + id: 'Function:src/caller.ts:caller', + name: 'caller', + type: 'Function', + filePath: 'src/caller.ts', + processes: [], + }, + ], + }, + pdgInterprocedural: { engine: 'symbol-graph', impactedCount: 1, byDepthCounts: { 1: 1 } }, + }), + ); + + expect(out).toContain('Statements downstream-dependent on src/svc.ts:8 (2):'); + expect(out).toContain('Inter-procedural symbol reach (1):'); + expect(out).toContain('Function caller → src/caller.ts'); }); it('flags slice truncation honestly', () => { diff --git a/gitnexus/test/unit/impact-pdg-metric-math.test.ts b/gitnexus/test/unit/impact-pdg-metric-math.test.ts index 8f809c01f..9240c6754 100644 --- a/gitnexus/test/unit/impact-pdg-metric-math.test.ts +++ b/gitnexus/test/unit/impact-pdg-metric-math.test.ts @@ -266,6 +266,34 @@ describe('impact-pdg metric math — unified axes', () => { expect([...pdg.interSymbol]).toEqual([]); }); + it('adapts unified PDG onto both statement and inter-symbol axes', () => { + const pdg = M.pdgUnifiedCis( + M.pdgLineCis([ + { line: 16, filePath: 'src/mixed.ts' }, + { line: 18, filePath: 'src/mixed.ts' }, + ]), + M.toKeySet([ + M.symbolKey('route', 'src/mixed.ts'), + M.symbolKey('fast', 'src/mixed.ts'), + M.symbolKey('slow', 'src/mixed.ts'), + ]), + gt, + ); + + expect([...pdg.intraLine].sort()).toEqual([ + 'statement:src/mixed.ts:16', + 'statement:src/mixed.ts:18', + ]); + expect([...pdg.interSymbol].sort()).toEqual([ + 'symbol:fast@src/mixed.ts', + 'symbol:slow@src/mixed.ts', + ]); + + const scored = M.scoreUnifiedAxes(pdg, M.unifiedAis(gt)); + expect(scored.intraLine.f1).toBe(1); + expect(scored.interSymbol.f1).toBe(1); + }); + it('scores composed-current as exact on both axes without a blended F1', () => { const ais = M.unifiedAis(gt); const cg = M.callgraphUnifiedCis(