feat(impact): statement-anchored PDG slice (line param) — the useful mode

The whole-symbol pdg seed was structurally empty: seeding a function's
entire blocks and excluding seeds leaves nothing (intra-procedural reach
stays inside the function). Add a 'line' statement anchor: impact({mode:
'pdg', line:N}) seeds the dependence BFS on the BasicBlock at 1-based
source line N within the symbol and returns the dependent STATEMENTS
(affectedStatements: {line,filePath,text}[], affectedStatementCount,
criterionLine). Verified: impact --mode pdg total --line 8 on the
accumulator returns lines [10,12] = the ground-truth slice.

- blockAnchorForStatement (a.startLine = line, no +1 — block lines are
  1-based and match source; bounded to the symbol span).
- pdgStatementsForBlocks resolves reachable blocks to source statements.
- line validated: PDG-only, positive integer; rejected on callgraph.
- Whole-symbol pdg keeps an honest empty note steering to line:<N>.
- tools.ts schema + CLI --line + eval-server statement render.

Refs: redesign after the harness/maintainer caught that symbol-level
pdg impact is empty in v1.
This commit is contained in:
Gergo Magyar 2026-06-16 10:23:02 +00:00
parent fe87408e06
commit e12abbd56f
10 changed files with 637 additions and 28 deletions

View file

@ -264,6 +264,61 @@ export function formatImpactResult(result: any): string {
);
}
// (2b) STATEMENT-ANCHORED SLICE (mode:'pdg' + line). When `criterionLine` is
// present the result is a statement slice: the seeded line plus the list of
// dependent statements (`affectedStatements: {line,filePath,text}[]`). Render
// those statements directly — this IS the useful output of statement mode —
// rather than the symbol-projection bucket below. Empty cases:
// - `pdg-no-block-at-line`: the line is blank / a comment / outside the
// body (no statement block) — print the steering note.
// - empty `affectedStatements` with `pdg-intra-procedural`: the line has no
// dependents in this direction — print the steering note.
// Each non-empty case also surfaces truncation honestly.
if (typeof result.criterionLine === 'number') {
const slice: any[] = Array.isArray(result.affectedStatements)
? result.affectedStatements
: [];
const count =
typeof result.affectedStatementCount === 'number'
? result.affectedStatementCount
: slice.length;
// File anchor for the heading — the seeded statement's file (every slice
// statement shares the function's file). Fall back to the target's file.
const anchorFile = slice[0]?.filePath || target?.filePath || name;
if (count === 0 || slice.length === 0) {
// No statement block at the line, or no dependents in this direction.
// Print the honest note (pdg-no-block-at-line or the no-dependence note)
// verbatim — never an empty "isolated" headline.
return (
`No statements ${direction}-dependent on ${anchorFile}:${result.criterionLine}.` +
(result.note ? `\n${result.note}` : '')
);
}
const slLines: string[] = [];
slLines.push(
`Statements ${direction}-dependent on ${anchorFile}:${result.criterionLine} (${count}):`,
);
for (const s of slice) {
const text = typeof s.text === 'string' ? s.text : '';
slLines.push(` L${s.line}: ${text}`);
}
// Truncation honesty — the slice may be a lower bound (depth or per-step
// LIMIT bound). Surface it the same way the symbol render does.
if (result.truncated) {
const by = result.truncatedBy ? ` (by ${result.truncatedBy})` : '';
slLines.push(
`⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`,
);
}
if (result.note) {
slLines.push('');
slLines.push(`ℹ️ ${result.note}`);
}
return slLines.join('\n').trim();
}
const items: any[] = (result.byDepth && result.byDepth[1]) || [];
const bucketCount = result.byDepthCounts?.[1] ?? items.length;
const pdgLines: string[] = [];

View file

@ -343,6 +343,10 @@ program
'Engine: callgraph (default) or pdg (opt-in, intra-procedural; needs analyze --pdg)',
'callgraph',
)
.option(
'--line <number>',
'1-based source line — PDG-only statement anchor (--mode pdg): slice the dependence from the statement at this line and show what depends on it',
)
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')

View file

@ -125,6 +125,7 @@ export async function impactCommand(
options?: {
direction?: string;
mode?: string;
line?: string;
repo?: string;
branch?: string;
uid?: string;
@ -163,6 +164,12 @@ export async function impactCommand(
const rawOffset = parseInt(options?.offset ?? '', 10);
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
// `--line` is a PDG-only statement anchor (1-based source line). Parse it to
// an integer when provided and thread it ONLY when present, so the backend's
// line-without-pdg / non-positive-integer validation fires on the real value
// rather than on a silently-dropped flag. A non-numeric `--line` parses to
// NaN, which the backend rejects as a non-positive integer (loud, not silent).
const parsedLine = options?.line !== undefined ? parseInt(options.line, 10) : undefined;
const result = await backend.callTool('impact', {
target: target || undefined,
target_uid: options?.uid,
@ -172,6 +179,8 @@ export async function impactCommand(
// Forward the engine selector; backend validates the enum (callgraph/pdg)
// and treats the default 'callgraph' identically to an omitted mode.
mode: options?.mode,
// PDG-only statement anchor — forwarded only when --line was given.
...(parsedLine !== undefined ? { line: parsedLine } : {}),
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,

View file

@ -129,6 +129,50 @@ function fnFileOf(id: string): string {
return parts.slice(1, parts.length - 3).join(':');
}
/** A reachable dependence block resolved to its source statement. */
export interface PdgStatement {
/** 1-based source line where the statement's block starts. */
line: number;
/** Repo-relative file path (parsed from the block id). */
filePath: string;
/** The statement's source text (BasicBlock.text), trimmed. */
text: string;
}
/**
* Resolve a set of reachable BasicBlock ids to their source statements
* (line + text), deduped by `(filePath, line)` and sorted by line. This is the
* useful output of a statement-anchored PDG slice — the dependent statements the
* change reaches. A query error propagates (no `.catch` swallow) so a DB failure
* is never silently reported as "no affected statements".
*/
async function pdgStatementsForBlocks(
lbugPath: string,
blockIds: string[],
exec: typeof executeParameterized,
): Promise<PdgStatement[]> {
if (blockIds.length === 0) return [];
const rows = await exec(
lbugPath,
`MATCH (b:BasicBlock) WHERE b.id IN $ids
RETURN b.id AS id, b.startLine AS line, b.text AS text`,
{ ids: blockIds },
);
const byKey = new Map<string, PdgStatement>();
for (const r of rows as any[]) {
const id = String(r.id ?? r[0] ?? '');
const line = Number(r.line ?? r[1] ?? 0);
if (!id || !Number.isFinite(line) || line <= 0) continue;
const filePath = fnFileOf(id);
const text = String(r.text ?? r[2] ?? '').trim();
const key = `${filePath}:${line}`;
if (!byKey.has(key)) byKey.set(key, { line, filePath, text });
}
return [...byKey.values()].sort((a, b) =>
a.filePath === b.filePath ? a.line - b.line : a.filePath < b.filePath ? -1 : 1,
);
}
// ── Block → owning-symbol projection types (U4) ──────────────────────────────
/**
@ -357,6 +401,10 @@ function assemblePdgImpactResult(input: {
target: { id: string; name: string; type: string; filePath: string };
direction: 'upstream' | 'downstream';
reachableBlocks: string[];
/** Reachable blocks resolved to source statements (the useful slice output). */
affectedStatements?: PdgStatement[];
/** The 1-based source line the slice was seeded on (statement mode only). */
criterionLine?: number;
projection: { symbols: OwningSymbol[]; unresolvedCount: number; ambiguousCount: number };
depthReached: number;
truncated: boolean;
@ -364,6 +412,8 @@ function assemblePdgImpactResult(input: {
}): Record<string, unknown> {
const { target, direction, reachableBlocks, projection } = input;
const { symbols, unresolvedCount, ambiguousCount } = projection;
const affectedStatements = input.affectedStatements ?? [];
const statementMode = typeof input.criterionLine === 'number';
// Items for the single collapsed bucket. Shaped like the call-graph byDepth
// items (`{ depth, id, name, type, filePath, processes }`) so consumers that
@ -390,13 +440,21 @@ function assemblePdgImpactResult(input: {
const byDepth: Record<number, unknown[]> = items.length > 0 ? { 1: items } : {};
const byDepthCounts: Record<number, number> = { 1: items.length };
const noteParts: string[] = [
`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.`,
];
const noteParts: string[] = statementMode
? [
`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.`,
]
: [
`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.`,
];
if (ambiguousCount > 0) {
noteParts.push(
`${ambiguousCount} owning-symbol ${ambiguousCount === 1 ? 'projection is' : 'projections are'} ` +
@ -425,6 +483,12 @@ function assemblePdgImpactResult(input: {
// PDG-specific epistemic marker — NOT the callgraph 'lower-bound'/DI copy.
epistemic: 'pdg-intra-procedural',
note: noteParts.join(' '),
// Statement-level slice: the dependent source statements (line + text) the
// change reaches. This is the primary useful output of statement mode; the
// accuracy harness scores against these lines.
...(statementMode ? { criterionLine: input.criterionLine } : {}),
affectedStatements,
affectedStatementCount: affectedStatements.length,
// Raw block-level detail retained alongside the symbol projection (U3 tests
// and the accuracy harness read these).
reachableBlocks,
@ -980,6 +1044,15 @@ interface ImpactParams {
* Validated in `_impactImpl`; any other value is a hard `{ error }`.
*/
mode?: ImpactMode;
/**
* Statement anchor for `mode:'pdg'` (1-based source line). When provided, the
* PDG traversal seeds the dependence slice on the BasicBlock(s) at THIS line
* within the target symbol — answering "what statements depend on the code at
* line N?" — instead of the whole-symbol seed (which is empty for a function,
* since its intra-procedural reach stays inside its own blocks). Only
* meaningful with `mode:'pdg'`; rejected for `mode:'callgraph'`.
*/
line?: number;
maxDepth?: number;
crossDepth?: number;
relationTypes?: string[];
@ -3505,6 +3578,40 @@ export class LocalBackend {
return { anchorClause: 'a.id STARTS WITH $idPrefix', queryParams: { idPrefix } };
}
/**
* Build a STATEMENT seed anchor: the BasicBlock(s) starting at a specific
* 1-based source `line` WITHIN the resolved symbol. This is what makes
* `mode:'pdg'` useful — seeding the dependence slice on a single statement
* (the thing being changed) rather than the whole symbol. A whole-symbol seed
* captures every intra-procedural block, so the reachable-minus-seed set is
* empty (all intra reach is within the seed); a statement seed leaves the
* other dependent statements reachable. `BasicBlock.startLine` is 1-based and
* matches the source line, so no `+1` offset applies here (unlike the symbol
* span, where the 0-based symbol bounds are shifted). Bounded to the symbol's
* own span when known, so a line shared with a sibling symbol can't leak.
*/
private blockAnchorForStatement(
sym: { filePath: string; startLine?: number; endLine?: number },
line: number,
): { anchorClause: string; queryParams: Record<string, unknown> } {
const idPrefix = `BasicBlock:${sym.filePath}:`;
if (
typeof sym.startLine === 'number' &&
typeof sym.endLine === 'number' &&
sym.endLine >= sym.startLine
) {
return {
anchorClause:
'a.id STARTS WITH $idPrefix AND a.startLine = $line AND a.startLine >= $symStart AND a.startLine <= $symEnd',
queryParams: { idPrefix, line, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 },
};
}
return {
anchorClause: 'a.id STARTS WITH $idPrefix AND a.startLine = $line',
queryParams: { idPrefix, line },
};
}
/**
* Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
* WAL-aware wrapper mirroring `context`.
@ -4843,6 +4950,31 @@ export class LocalBackend {
}
const mode = modeResult.mode;
// `line` is a PDG-only statement anchor. Reject it on the callgraph path
// rather than silently ignore (the symbol→symbol BFS has no statement notion).
if (params.line !== undefined && mode !== 'pdg') {
return {
error: `Parameter 'line' is only supported with mode:'pdg' (it anchors the dependence slice on a statement). Remove it or set mode:'pdg'.`,
target: { name: params.target },
direction: params.direction,
impactedCount: 0,
risk: 'UNKNOWN',
};
}
// A provided `line` must be a positive integer.
if (
params.line !== undefined &&
(!Number.isInteger(params.line) || (params.line as number) < 1)
) {
return {
error: `Parameter 'line' must be a positive integer (1-based source line), got ${JSON.stringify(params.line)}.`,
target: { name: params.target },
direction: params.direction,
impactedCount: 0,
risk: 'UNKNOWN',
};
}
if (mode === 'pdg') {
// KTD12 — param-compatibility hard rejections (decided as errors, NOT
// silent ignores and NOT an `ignoredParams` echo). Each names a symbol-
@ -5143,6 +5275,7 @@ export class LocalBackend {
symType,
direction,
maxDepth,
line: params.line,
limit: Number.isFinite(params.limit) ? params.limit : 100,
// KTD2 extraction-seam discipline: hand the engine its DB dependency
// explicitly rather than `this.`-binding it, so the traversal (U3/U4)
@ -5212,9 +5345,15 @@ export class LocalBackend {
direction: 'upstream' | 'downstream';
maxDepth: number;
limit: number;
/** Statement anchor (1-based source line) — see ImpactParams.line. */
line?: number;
executeParameterized: typeof executeParameterized;
}): Promise<any> {
const { repo, sym, direction, maxDepth, executeParameterized: exec } = deps;
const { repo, sym, direction, maxDepth, line, executeParameterized: exec } = deps;
// `line` present ⇒ statement-anchored slice (the useful mode); absent ⇒
// whole-symbol seed (intra-procedural reach collapses to empty for a
// function — kept for back-compat, with a note steering the caller to `line`).
const statementMode = typeof line === 'number' && Number.isInteger(line) && line >= 1;
// `target` carries the call-graph-compatible shape (id/name/type/filePath) so
// `collectImpactSymbolUids` keys on it identically to a callgraph result.
const target = {
@ -5247,7 +5386,9 @@ export class LocalBackend {
// So build the seed anchor DIRECTLY from the resolved symbol's
// [startLine+1, endLine+1] window — the same window `resolveBlockAnchor`'s
// symbol branch produces, without re-running `resolveSymbolCandidates`.
const { anchorClause, queryParams } = this.blockAnchorForResolvedSymbol(sym);
const { anchorClause, queryParams } = statementMode
? this.blockAnchorForStatement(sym, line as number)
: this.blockAnchorForResolvedSymbol(sym);
const seedRows = await exec(
repo.lbugPath,
@ -5274,18 +5415,27 @@ export class LocalBackend {
mode: 'pdg',
target,
direction,
...(statementMode ? { criterionLine: line } : {}),
reachableBlocks: [],
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
truncated: false,
depthReached: 0,
// "No PDG body" — structurally no CFG/blocks for this symbol kind.
epistemic: 'no-pdg-body',
note:
`'${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. ` +
`Use mode:'callgraph' for its inter-procedural blast radius.`,
// statementMode: the requested line has no statement block inside the
// symbol (blank line, comment, outside the body, or a line the CFG did
// not materialise). Distinct from "no PDG body".
epistemic: statementMode ? 'pdg-no-block-at-line' : 'no-pdg-body',
note: statementMode
? `No PDG statement block starts at line ${line} within '${sym.name}' ` +
`(${sym.filePath}). The line may be blank, a comment, a brace, or outside ` +
`the symbol's body. Pass a line that begins an executable statement.`
: `'${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.`,
impactedCount: 0,
risk: 'UNKNOWN',
// KTD8 parity fields so a consumer iterating byDepth / reading the
@ -5360,24 +5510,41 @@ export class LocalBackend {
? 'limit'
: undefined;
// ── Resolve the reachable blocks to source statements (line + text) ────────
// This is the useful output of statement mode: the dependent statements the
// change at `line` reaches. Fetched once for the whole reachable set; sorted
// by line. Failure surfaces (no `.catch` swallow) rather than masquerading
// as "no affected statements".
const affectedStatements = await pdgStatementsForBlocks(repo.lbugPath, reachableBlocks, exec);
// ── Has a PDG body but no intra-procedural dependence reachability ─────────
// Distinct from "no PDG body": the function exists and has blocks, but no
// CDG/REACHING_DEF edge leaves the target's blocks in this direction. Still
// not a confident zero — surface the explicit note + UNKNOWN (KTD6/KTD8).
// CDG/REACHING_DEF edge leaves the target's blocks in this direction. For a
// WHOLE-SYMBOL seed this is the expected (and uninformative) result — every
// intra-procedural block is already a seed — so the note steers to `line`.
// Still not a confident zero — explicit note + UNKNOWN (KTD6/KTD8).
if (reachableBlocks.length === 0) {
return {
mode: 'pdg',
target,
direction,
...(statementMode ? { criterionLine: line } : {}),
impactedCount: 0,
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
note:
`'${sym.name}' has a PDG body but no intra-procedural ${direction} dependence edges ` +
`(no CDG/REACHING_DEF reachability from its blocks). This is distinct from "no PDG body". ` +
`Use mode:'callgraph' for its inter-procedural blast radius.`,
note: statementMode
? `No statement in '${sym.name}' is ${direction}-dependent on line ${line} ` +
`(no CDG/REACHING_DEF reachability from that statement). The line may have no ` +
`dependents in this direction.`
: `'${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.`,
reachableBlocks: [] as string[],
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
depthReached,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
@ -5403,6 +5570,8 @@ export class LocalBackend {
},
direction,
reachableBlocks,
affectedStatements,
criterionLine: statementMode ? (line as number) : undefined,
projection,
depthReached,
truncated,

View file

@ -411,6 +411,8 @@ Returns affected symbols grouped by depth, plus risk assessment, affected execut
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).
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.
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.
@ -459,6 +461,12 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
description:
"Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (current behavior). 'pdg' = opt-in, intra-procedural Program Dependence Graph traversal (control + data dependence); requires an index built with `gitnexus analyze --pdg`. The pdg mode is incompatible with relationTypes/crossDepth/minConfidence and with @group targets — each is rejected, not silently ignored.",
},
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).",
},
file_path: {
type: 'string',
description: 'File path hint to disambiguate common names',

View file

@ -194,6 +194,58 @@ withTestLbugDB(
});
});
// ── Statement-mode result shape (criterionLine + slice + KTD8 parity) ─────
describe('statement-mode result carries the slice fields AND the KTD8 parity fields', () => {
it('a line-seeded result has criterionLine/affectedStatements/affectedStatementCount', async () => {
const result = await backend.callTool('impact', {
target: 'accum',
direction: 'downstream',
mode: 'pdg',
line: 72,
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
// Statement-mode-specific fields.
expect(result.criterionLine).toBe(72);
expect(Array.isArray(result.affectedStatements)).toBe(true);
expect(result.affectedStatementCount).toBe(result.affectedStatements.length);
expect(result.affectedStatementCount).toBe(2);
for (const s of result.affectedStatements) {
expect(s).toHaveProperty('line');
expect(s).toHaveProperty('filePath');
expect(s).toHaveProperty('text');
}
// The slice statements are the downstream-dependent ones (lines 10, 12).
const lines = (result.affectedStatements as any[]).map((s) => s.line).sort((a, b) => a - b);
expect(lines).toEqual([74, 76]);
});
it('the statement-mode result ALSO carries the KTD8 parity fields (byDepth/target/risk/empty processes-modules)', async () => {
const result = await backend.callTool('impact', {
target: 'accum',
direction: 'downstream',
mode: 'pdg',
line: 72,
});
// byDepth is the single collapsed bucket (block-hops ≠ call-hops).
expect(Object.keys(result.byDepth)).toEqual(['1']);
expect(result.byDepthCounts['1']).toBe(result.byDepth['1'].length);
// target carries the call-graph-compatible shape.
expect(result.target.id).toBe('func:accum');
expect(result.target.name).toBe('accum');
expect(result.target.filePath).toBe(F);
expect(typeof result.target.type).toBe('string');
// risk is the UNKNOWN sentinel (never a confident LOW).
expect(result.risk).toBe('UNKNOWN');
expect(result.risk).not.toBe('LOW');
// Empty processes/modules — consumers coalesce [].
expect(result.affected_processes).toEqual([]);
expect(result.affected_modules).toEqual([]);
expect(result.summary.processes_affected).toBe(0);
expect(result.summary.modules_affected).toBe(0);
});
});
// ── Consumer safety: group cross-impact treats the PDG result correctly ───
describe('group consumer safety (cross-impact.ts)', () => {
it('collectImpactSymbolUids collects the PDG owning-symbol UIDs (non-zero)', async () => {
@ -423,6 +475,27 @@ withTestLbugDB(
await edge('CDG', K2, T, 'T');
await edge('CDG', T, U, 'T');
// ── Statement-anchored fixture `accum` (mode:'pdg' + line) ────────────────
// Self-contained multi-statement fn at 0-based [70,80] (window [71,81]); a
// line range that does NOT overlap S@11 / D@21,22 / K@31,32 above, so its
// whole-symbol seed cannot pick up an unrelated block. Every dependence
// stays inside it, so a STATEMENT seed slices the dependent statements.
// 71: let sum = 0; (A) 72: for (…) { (B, criterion)
// 74: sum = sum + x; (C) 76: return sum; (D)
// CDG B→C; RD A→C, C→D ⇒ downstream from line 72 = {C@74, D@76}.
await fn('func:accum', 'accum', 70, 80);
const AccA = `BasicBlock:${F}:71:0:0`; // line 71
const AccB = `BasicBlock:${F}:71:0:1`; // line 72
const AccC = `BasicBlock:${F}:71:0:2`; // line 74
const AccD = `BasicBlock:${F}:71:0:3`; // line 76
await block(AccA, 71, 'let sum = 0;');
await block(AccB, 72, 'for (const x of xs) {');
await block(AccC, 74, 'sum = sum + x;');
await block(AccD, 76, 'return sum;');
await edge('CDG', AccB, AccC, 'loop');
await edge('REACHING_DEF', AccA, AccC, 'sum');
await edge('REACHING_DEF', AccC, AccD, 'sum');
// ── Windows drive-colon path fixture (exercises fnFileOf split-from-right) ─
// Separate file `C:/src/win.ts`, isolated from `target`'s graph so the
// existing impactedCount/byDepth assertions are untouched. `winFn` is its

View file

@ -163,6 +163,89 @@ withTestLbugDB(
});
});
// Helper: the slice statements' lines (sorted) for an `accum` line-seeded call.
const sliceLines = (result: any): number[] =>
[...((result?.affectedStatements as any[]) ?? [])].map((s) => s.line).sort((a, b) => a - b);
describe('statement-anchored seed (mode:pdg + line)', () => {
it('downstream from line 72 returns exactly the statements dependent on it (NOT the whole symbol)', async () => {
const result = await backend.callTool('impact', {
target: 'accum',
direction: 'downstream',
mode: 'pdg',
line: 72,
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.criterionLine).toBe(72);
// line 72 (the loop header B) controls C@74, whose sum flows to D@76.
expect(sliceLines(result)).toEqual([74, 76]);
expect(result.affectedStatementCount).toBe(2);
// The dependent statements carry the real source line + text.
const byLine = new Map((result.affectedStatements as any[]).map((s) => [s.line, s]));
expect(byLine.get(74).text).toBe('sum = sum + x;');
expect(byLine.get(74).filePath).toBe(F);
expect(byLine.get(76).text).toBe('return sum;');
// It is NOT the whole-symbol set — line 71 (the def above the criterion) is
// upstream of the criterion, not downstream of it, so it must be absent.
expect(sliceLines(result)).not.toContain(71);
});
it('upstream from line 74 returns the statements line 74 depends on (the def + the controller)', async () => {
const result = await backend.callTool('impact', {
target: 'accum',
direction: 'upstream',
mode: 'pdg',
line: 74,
});
expect(result.error).toBeUndefined();
expect(result.criterionLine).toBe(74);
// C@74 depends on A@71 (RD def of sum) and B@72 (CDG controller).
expect(sliceLines(result)).toEqual([71, 72]);
expect(result.affectedStatementCount).toBe(2);
// NOT the whole-symbol set — D@76 is downstream of line 74, never upstream.
expect(sliceLines(result)).not.toContain(76);
});
it('whole-symbol (no line) is empty and steers the caller to line:<N>', async () => {
// `accum`'s entire dependence stays inside its own [7,13] window, so a
// whole-symbol seed reaches nothing (every block is a co-seed) — the
// structurally-empty WHOLE-SYMBOL case.
const result = await backend.callTool('impact', {
target: 'accum',
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
// No criterionLine (whole-symbol mode), an empty slice, and the steering note.
expect(result.criterionLine).toBeUndefined();
expect(result.affectedStatements).toEqual([]);
expect(result.affectedStatementCount).toBe(0);
expect(result.note).toMatch(/WHOLE-SYMBOL/);
expect(result.note).toMatch(/line:<N>|Pass line/i);
// Still never a confident "safe" zero.
expect(result.risk).not.toBe('LOW');
});
it('a line with no statement block → epistemic pdg-no-block-at-line (distinct from no-pdg-body)', async () => {
const result = await backend.callTool('impact', {
target: 'accum',
direction: 'downstream',
mode: 'pdg',
line: 73, // blank line inside accum — no block starts here
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.criterionLine).toBe(73);
// Distinct from the no-PDG-body epistemic (the line has no statement block).
expect(result.epistemic).toBe('pdg-no-block-at-line');
expect(result.epistemic).not.toBe('no-pdg-body');
expect(result.affectedStatements).toEqual([]);
expect(result.risk).not.toBe('LOW');
});
});
describe('truncation signalling', () => {
it('maxDepth=1 truncates the chain and flags truncated (not silently short)', async () => {
const result = await backend.callTool('impact', {
@ -327,6 +410,37 @@ withTestLbugDB(
await edge('CDG', S, K1, 'T');
await edge('CDG', K1, K2, 'T');
// ── Statement-anchored fixture `accum` (mode:'pdg' + line) ────────────────
// A SELF-CONTAINED multi-statement function whose every dependence stays
// inside its own [71,81] window — so a WHOLE-SYMBOL seed reaches nothing
// (every block is a co-seed) while a STATEMENT seed (line N) yields exactly
// the statements dependent on line N. Lives in a line range that does NOT
// overlap the `target`/`up`/`down`/`ctl` blocks above, so its whole-symbol
// seed cannot pick up an unrelated block (the window is `[startLine+1,
// endLine+1]`). Mirrors the accumulator idiom:
// 71: let sum = 0; (A — RD def of sum)
// 72: for (const x of xs) { (B — CDG controller of the loop body)
// 73: (blank — NO block, the no-block-at-line case)
// 74: sum = sum + x; (C — accumulate; controlled by B, uses A)
// 76: return sum; (D — RD use of C's sum def)
// Edges (all intra-`accum`):
// CDG: B(72) → C(74) the loop controls the accumulate body
// RD: A(71) → C(74) sum's initial def reaches the accumulate use
// RD: C(74) → D(76) the accumulated sum flows to the return
// ⇒ downstream from line 72 = {C@74, D@76}; upstream from line 74 = {A@71, B@72}.
await fn('func:accum', 'accum', 70, 80); // window [71,81]
const AccA = `BasicBlock:${F}:71:0:0`; // line 71
const AccB = `BasicBlock:${F}:71:0:1`; // line 72 (same fn, distinct blockIdx)
const AccC = `BasicBlock:${F}:71:0:2`; // line 74
const AccD = `BasicBlock:${F}:71:0:3`; // line 76
await block(AccA, 71, 'let sum = 0;');
await block(AccB, 72, 'for (const x of xs) {');
await block(AccC, 74, 'sum = sum + x;');
await block(AccD, 76, 'return sum;');
await edge('CDG', AccB, AccC, 'loop');
await edge('REACHING_DEF', AccA, AccC, 'sum');
await edge('REACHING_DEF', AccC, AccD, 'sum');
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'traversal-repo',

View file

@ -1496,6 +1496,60 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
},
);
it.each([['callgraph'], [undefined]])(
'line param with mode:%j → structured {error} (line is PDG-only), never a callgraph result',
async (mode) => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode: mode as any,
line: 8,
});
expect(result.error).toMatch(/'line' is only supported with mode:'pdg'/);
expect(result.risk).toBe('UNKNOWN');
// A PDG-only param on the callgraph path must NOT silently run the BFS.
expect(bfsSpy).not.toHaveBeenCalled();
},
);
it.each([[0], [-1], [1.5]])(
"mode:'pdg' + non-positive-integer line %j → structured {error}, never routed to traversal",
async (badLine) => {
resolveSingleTarget();
const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG');
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode: 'pdg',
line: badLine as any,
});
expect(result.error).toMatch(/'line' must be a positive integer/);
expect(result.risk).toBe('UNKNOWN');
// The validation fires BEFORE the traversal — a bad line never seeds a slice.
expect(pdgSpy).not.toHaveBeenCalled();
},
);
it("mode:'pdg' + line:8 routes to the PDG traversal (no validation error, never the BFS)", async () => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG');
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
mode: 'pdg',
line: 8,
});
// A valid line routes cleanly into the PDG engine — no line/mode error.
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();
});
it.each([
['relationTypes', { relationTypes: ['CALLS'] }],
['crossDepth', { crossDepth: 2 }],

View file

@ -214,8 +214,10 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
expect(out).not.toContain('No downstream dependencies found');
});
it('renders has-body-but-no-dependence as not-isolated, with the cross-function caveat', () => {
// `_runImpactPDG` reachableBlocks.length === 0 path (body exists, no edges).
it('renders whole-symbol-empty as not-isolated, steering the caller to line:<N>', () => {
// `_runImpactPDG` reachableBlocks.length === 0 path WITHOUT a line (whole-
// symbol seed). The note now frames it as a structurally-empty WHOLE-SYMBOL
// slice and steers to `line:<N>` (the useful statement-anchored mode).
const out = formatImpactResult({
mode: 'pdg',
target: {
@ -229,11 +231,15 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
note:
"'noop' has a PDG body but no intra-procedural downstream dependence edges " +
'(no CDG/REACHING_DEF reachability from its blocks). This is distinct from "no PDG body". ' +
"Use mode:'callgraph' for its inter-procedural blast radius.",
"'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.',
reachableBlocks: [],
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
depthReached: 1,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
@ -244,12 +250,114 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
affected_modules: [],
});
expect(out).toContain('no intra-procedural PDG-dependent symbols');
// The new note steers to the statement-anchored mode.
expect(out).toContain('WHOLE-SYMBOL');
expect(out).toMatch(/line:<N>/);
// The caveat may reference the word "isolated" to disclaim it, but the
// confident callgraph "appears isolated." headline must be absent.
expect(out).not.toContain('appears isolated');
expect(out).not.toContain('No downstream dependencies found');
expect(out.toLowerCase()).toContain('cross-function');
});
// ── Statement-anchored (mode:'pdg' + line) rendering ──────────────────────
// A representative statement-mode result, shaped like `assemblePdgImpactResult`
// emits when seeded on a line: criterionLine + affectedStatements + count.
function pdgStatementSlice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
mode: 'pdg',
target: {
id: 'Function:src/svc.ts:accum',
name: 'accum',
type: 'Function',
filePath: 'src/svc.ts',
},
direction: 'downstream',
criterionLine: 8,
affectedStatements: [
{ line: 10, filePath: 'src/svc.ts', text: 'sum = sum + x;' },
{ line: 12, filePath: 'src/svc.ts', text: 'return sum;' },
],
affectedStatementCount: 2,
impactedCount: 1,
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.",
reachableBlocks: ['b1', 'b2'],
blockCount: 2,
depthReached: 2,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
byDepthCounts: { 1: 1 },
affected_processes: [],
affected_modules: [],
byDepth: { 1: [] },
...overrides,
};
}
it('renders a statement slice as an L<line>: <text> list under the criterion-line heading', () => {
const out = formatImpactResult(pdgStatementSlice());
// Heading carries direction + file:criterionLine + count.
expect(out).toContain('Statements downstream-dependent on src/svc.ts:8 (2):');
// 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('flags slice truncation honestly', () => {
const out = formatImpactResult(pdgStatementSlice({ truncated: true, truncatedBy: 'depth' }));
expect(out).toContain('Truncated');
expect(out).toContain('by depth');
});
it('renders a no-block-at-line result as the steering note, never an empty isolated headline', () => {
// `_runImpactPDG` seedBlocks.length === 0 in statement mode.
const out = formatImpactResult({
mode: 'pdg',
target: {
id: 'Function:src/svc.ts:accum',
name: 'accum',
type: 'Function',
filePath: 'src/svc.ts',
},
direction: 'downstream',
criterionLine: 9,
reachableBlocks: [],
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
truncated: false,
depthReached: 0,
epistemic: 'pdg-no-block-at-line',
note:
"No PDG statement block starts at line 9 within 'accum' (src/svc.ts). The line may be " +
"blank, a comment, a brace, or outside the symbol's body. Pass a line that begins an " +
'executable statement.',
impactedCount: 0,
risk: 'UNKNOWN',
byDepth: {},
byDepthCounts: { 1: 0 },
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
});
expect(out).toContain('No statements downstream-dependent on src/svc.ts:9');
expect(out).toContain('No PDG statement block starts at line 9');
expect(out).not.toContain('appears isolated');
expect(out).not.toContain('PDG-dependent symbols');
});
});
describe('formatImpactResult — callgraph rendering is UNCHANGED (regression guard)', () => {

View file

@ -134,6 +134,21 @@ describe('GITNEXUS_TOOLS', () => {
expect(impactTool.inputSchema.required).toContain('direction');
});
it('impact tool advertises the PDG-only `line` statement anchor (integer, min 1, not required)', () => {
const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!;
const line = (impactTool.inputSchema.properties as Record<string, any>).line;
expect(line).toBeDefined();
expect(line.type).toBe('integer');
expect(line.minimum).toBe(1);
// Statement-anchored slice is optional — never required.
expect(impactTool.inputSchema.required).not.toContain('line');
// The description names the mode:'pdg' statement-anchor semantics.
expect(line.description).toMatch(/statement anchor/i);
expect(line.description).toMatch(/pdg/i);
// The top-level description mentions the statement-anchored slice.
expect(impactTool.description).toMatch(/statement-anchored|STATEMENT-ANCHORED/);
});
it('rename tool requires new_name', () => {
const renameTool = GITNEXUS_TOOLS.find((t) => t.name === 'rename')!;
expect(renameTool.inputSchema.required).toContain('new_name');