feat(impact): unify pdg symbol reach

This commit is contained in:
Gergo Magyar 2026-06-18 08:51:19 +00:00
parent 0271da39e3
commit a9fd55a91b
13 changed files with 471 additions and 214 deletions

View file

@ -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

View file

@ -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,

View file

@ -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. */

View file

@ -201,7 +201,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.${
hasPdg
? ` For finer, intra-procedural precision within a function, add \`mode: "pdg"\` with \`line: <N>\` — it returns statement-level \`affectedStatements\` over CDG + REACHING_DEF, but does NOT model cross-function impact; no-layer/degraded PDG results are UNKNOWN-risk notes (\`--pdg\` layer).`
? ` For unified PDG impact, add \`mode: "pdg"\` with optional \`line: <N>\` — 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))}})\`.

View file

@ -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:<N>.`,
);
} 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.',
);
}

View file

@ -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<number, unknown[]> = {};
const byDepthCounts: Record<number, number> = {};
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.
*

View file

@ -298,6 +298,14 @@ export interface PdgImpactParityFields {
affected_modules: unknown[];
}
export interface PdgInterproceduralImpact {
engine: 'symbol-graph';
impactedCount: number;
byDepthCounts: Record<number, number>;
byDepth: Record<number, unknown[]>;
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<number, unknown[]>;
interproceduralByDepthCounts?: Record<number, number>;
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<PdgImpactRes
: `'${sym.name}' 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. ` +
`Pass line:<N> 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<PdgImpactRes
: `'${sym.name}' has a PDG body but a WHOLE-SYMBOL ${direction} slice is empty: ` +
`intra-procedural dependence stays inside the function, so every reachable block ` +
`is already part of the seed. Pass line:<N> 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: [],

View file

@ -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 symbolsymbol 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 symbolsymbol 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',

View file

@ -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 files 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();
}

View file

@ -157,6 +157,7 @@ describe('generateAIContextFiles', () => {
expect(withPdg).toContain('under what condition does X run');
expect(withPdg).toContain('line: <N>');
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);

View file

@ -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([
{

View file

@ -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<string, unknown> = {}): Record<string, un
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.",
'dependence blocks (downstream over CDG + REACHING_DEF). Inter-procedural symbol reach ' +
'is included using the resolved symbol graph; statement-level PDG reach remains in affectedStatements.',
reachableBlocks: ['b1', 'b2', 'b3', 'b4'],
blockCount: 4,
depthReached: 2,
@ -106,12 +105,12 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
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:<N> 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:<N>/);
@ -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<line>: <text>`.
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', () => {

View file

@ -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(