mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
fe87408e06
commit
e12abbd56f
10 changed files with 637 additions and 28 deletions
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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)')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue