GitNexus/gitnexus/test/unit/cli-impact-pdg-format.test.ts
Gergő Magyar fbffa96554
fix(lbug/mcp): exact symbol content + 0-based line storage with 1-based MCP display (#2377, #2379) (#2380)
* fix(lbug): store exact symbol content snippets

* fix(ingestion): emit 0-based line numbers for COBOL/JCL/scope/markdown nodes

COBOL/JCL processors, the scope-graph emitter, and the markdown Section
emitter stored 1-based startLine/endLine, unlike every tree-sitter node
(0-based). The exact-content slice (#2379) then dropped each symbol's
declaration line for those languages. Convert to 0-based at the graph-node
emission boundary via toZeroBasedLine — leaving parser-internal .line values,
L${line} node/edge IDs, and containment checks untouched.

Refs #2377, #2379

* refactor(lbug): single source of truth for symbol-content labels

Extract SYMBOL_NODE_LABELS so the exact-content label set can't drift the way
the inline copy did in #2379. csv-generator derives EXACT_SYMBOL_CONTENT_LABELS
from it; manifest-extractor's near-identical allowlist is left behavior-unchanged
(intentional subset, #2325-test-locked) with a documented cross-reference.

Refs #2379

* test(ingestion): cover 0-based emitter output and pin exact-content slicing

- csv-pipeline: replace the blank-buffer fixture (a +/-1 shift silently passed)
  with directly-adjacent neighbors; add one-line-symbol and Section (+/-2 fallback)
  cases.
- cobol resolver: assert COBOL Module and JCL job/step emit 0-based startLine.
- markdown CRLF: update Section startLine/endLine expectations to 0-based.

Refs #2377, #2379

* feat(mcp): present 1-based line numbers in context/query/impact tools

GraphNode startLine/endLine are stored 0-based (tree-sitter rows), which
surprised users querying them (they don't line up with editors/sed). Add
toDisplayLine and apply it at the context/query/impact response boundaries so
line numbers are editor/sed-aligned. Raw cypher stays 0-based (documented in the
schema resource); BasicBlock/PDG statement lines (already 1-based) and internal
join params are left untouched.

Refs #2377

* test(mcp): assert 1-based tool exposure with raw cypher staying 0-based

context() reports startLine+1 (editor/sed aligned); a raw cypher RETURN of the
same node keeps the stored 0-based value. Guards against double-conversion and
leaking the display shift into raw results.

Refs #2377

* fix(mcp): stop query() double-converting BM25 line numbers

bm25Search applied toDisplayLine to its result rows, and query()'s
aggregation loop applied it again, so BM25-matched symbols reported
lines shifted +2 (stored 0-based 41 read as 43, not 42) while
semantic-matched symbols were correct. bm25Search is called only from
query(); return raw 0-based rows and let the single aggregation-loop
conversion handle both retrievers.

Adds a query() BM25 regression test asserting stored 41 -> 42 (would
be 43 if double-converted), which the prior mcp-line-display test —
covering only context()+cypher — never exercised. (#2380, #2377)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): use ?? not || so first-line symbols keep their line number

`sym.startLine || sym[4]` treated a legitimate 0-based startLine of 0
as absent, so context()/query() dropped startLine/endLine for every
symbol on line 1 of its file — every COBOL Module (toZeroBasedLine(1)
= 0) and markdown h1. `??` only falls through to the positional
fallback on null/undefined, preserving a real 0. This also repairs the
rename definition-edit path, which consumes context()'s value.

Adds a context() first-line (startLine:0 -> 1) assertion. (#2380, #2377)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): make group/cross-repo trace line numbers 1-based consistently

A group/cross-repo trace presented 1-based endpoints (via
resolveSymbolForGroup) but 0-based hops (tagHops copies port.trace
output verbatim), so one response mixed bases. Wrap the trace port
adapter (traceForGroup) to convert hop lines to 1-based too, matching
the endpoints. Single-repo trace dispatches directly (not through this
port) and stays 0-based — full single-repo parity is a tracked
follow-up. core/group stays display-agnostic (no mcp import).

Extends the cross-trace e2e test to assert hops share the endpoints'
base (checkout 10 -> 11, getUsers 1 -> 2). (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): present explain/pdg_query anchor line 1-based

resolveBlockAnchor converted its ambiguous-candidate lines to 1-based
but left the resolved-target anchor raw 0-based, so the same tool
reported two bases depending on whether the target was ambiguous.
Convert the display anchor to 1-based via toDisplayLine. The BasicBlock
join param (symStart: sym.startLine + 1) is untouched — it targets the
1-based BasicBlock id space, not display.

Asserts the resolved anchor is 1-based (targetFn stored 10 -> 11). (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): bump schema + PDG result versions for the line-number change

The 0-based storage flip for COBOL/JCL/markdown/scope (#2377/#2379)
changed on-disk line semantics, and the PDG result startLine is now
1-based (#2380). Neither shipped a version bump, so an incremental
re-analyze would preserve old 1-based rows (mixed-base index rendered
one line too high) and PDG consumers got no signal.

- INCREMENTAL_SCHEMA_VERSION 5 -> 6 (forces a one-time full re-analyze)
- PDG_RESULT_VERSION 1 -> 2 (result-shape discriminator)

Updates the version-pinning tests, the pdgResultVersion result type,
and the tools.ts PDG output-contract doc. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(group): guard manifest label list against SYMBOL_NODE_LABELS drift

manifest-extractor's CUSTOM_CONTRACT_RESOLVE_QUERY hand-lists the
contract-resolvable labels as a deliberate subset of the shared
SYMBOL_NODE_LABELS, guarded only by a comment — the same drift class
(#2379) the shared-set refactor eliminated elsewhere. Derive the
query's label set and assert it is a strict subset whose difference is
exactly {Namespace, Variable, Module}, so adding a symbol label without
a conscious manifest decision fails. Query string stays literal
(#2325-test-locked). (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(mcp): document which tools present 1-based vs 0-based line numbers

The schema-resource note listed only context/query/impact as 1-based.
After the trace/anchor fixes it now enumerates the full set —
context, query, impact, group/cross-repo trace, and explain/pdg_query
anchors are 1-based; raw Cypher and single-repo trace stay 0-based
(full single-repo-trace parity is a tracked follow-up); BasicBlock/PDG
statement lines are separately 1-based. (#2377, #2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): pin impact() line-value display (close the coverage gap)

The prior mcp-line-display test only asserted context() + raw cypher,
which is why the query() double-conversion (#2380) shipped green. Adds
an impact() line-value assertion via the ambiguous-candidate path (the
only impact response that surfaces a per-candidate line): two same-name
symbols force ambiguity and the candidate at stored 0-based 41 must
read 42. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): fix stale rename #2283 mock after 1-based context display

rename resolves its symbol via context(), which now presents startLine
1-based (#2377), then subtracts 1 to recover the 0-based file index.
The #2283 mock stored startLine:1 but put `oldName` on the file's line
0, so after the 1-based shift the definition edit no longer matched and
the write-failure path never fired — the test read 'success' instead of
'partial'. Align the mock content to its stored line (oldName on
0-based line 1). Pre-existing failure surfaced once ubuntu/coverage
completed on this branch. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): consolidate line-display tests into one shared DB block

The query()/BM25 case had spun up a second full LadybugDB + FTS setup;
fold it into the single existing block (adding FTS + the Zqxwvbm seed
there) so the file builds one DB, not two. Trims per-file setup cost —
relevant to the Windows platform-sensitive suite's under-load 15-minute
timeout. Same five assertions, all green. (#2380)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: kigland <shuaizhicheng336@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:16:45 +01:00

558 lines
20 KiB
TypeScript

/**
* U5 — CLI / consumer rendering for PDG (`mode:'pdg'`) impact results.
*
* Guards the KTD8 presentation contract: PDG results must render HONESTLY —
* - 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";
* - the callgraph DI/dynamic-dispatch epistemic copy is NEVER printed for PDG.
*
* And the standing interchangeability contract (KTD8): `mode:'callgraph'`
* rendering stays byte-identical (regression guard).
*/
import { describe, expect, it } from 'vitest';
import { formatImpactResult, getNextStepHint } from '../../src/cli/eval-server.js';
// A representative PDG findings result, shaped exactly like
// `assemblePdgImpactResult` (pdg-impact.ts) emits.
function pdgFindings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const items = [
{
depth: 1,
id: 'Function:src/svc.ts:applyDiscount',
name: 'applyDiscount',
type: 'Function',
filePath: 'src/svc.ts',
processes: [],
},
{
depth: 1,
id: 'Function:src/svc.ts:finalizeTotal',
name: 'finalizeTotal',
type: 'Function',
filePath: 'src/svc.ts',
processes: [],
},
];
return {
mode: 'pdg',
pdgResultVersion: 2,
target: {
id: 'Function:src/svc.ts:computeTotal',
name: 'computeTotal',
type: 'Function',
filePath: 'src/svc.ts',
},
direction: 'downstream',
impactedCount: 2,
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
note:
"mode:'pdg' — intra-procedural Program Dependence Graph. 2 owning symbols reached via 4 " +
'dependence blocks (downstream over CDG + REACHING_DEF). 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,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
summary: { direct: 2, processes_affected: 0, modules_affected: 0 },
byDepthCounts: { 1: 2 },
affected_processes: [],
affected_modules: [],
byDepth: { 1: items },
...overrides,
};
}
describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
it('renders a PDG ambiguous target without fabricated zero blast-radius counts', () => {
const out = formatImpactResult({
status: 'ambiguous',
mode: 'pdg',
message:
"Found 2 symbols matching 'login'. Disambiguate with target_uid for a single authoritative PDG result.",
target: { name: 'login' },
direction: 'upstream',
totalCandidates: 2,
impactedCount: 0,
risk: 'UNKNOWN',
candidates: [
{
uid: 'func:login:1',
name: 'login',
kind: 'Function',
filePath: 'src/auth.ts',
line: 5,
score: 1,
},
{
uid: 'func:login:2',
name: 'login',
kind: 'Function',
filePath: 'src/admin/login.ts',
line: 8,
score: 0.91,
},
],
});
expect(out).toContain('login: AMBIGUOUS');
expect(out).toContain('PDG impact was not computed');
expect(out).toContain('func:login:1');
expect(out).not.toContain('Max blast radius 0');
expect(out).not.toContain('[0 upstream');
});
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('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');
// The callgraph "Blast radius for ... will break if changed" headline must
// not leak into PDG output.
expect(out).not.toContain('Blast radius for');
// The affected symbols are listed.
expect(out).toContain('applyDiscount');
expect(out).toContain('finalizeTotal');
expect(out).toContain('src/svc.ts');
// The 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');
expect(out).not.toContain('binding via DI');
});
it('carries the stable pdgResultVersion discriminator on the findings result', () => {
// The PDG result family advertises a contract version (FIX #2) so external
// MCP/agent consumers can version against future shape evolution. It is a
// mode:'pdg'-only field — never on the default callgraph result.
expect(pdgFindings()).toMatchObject({ mode: 'pdg', pdgResultVersion: 2 });
});
it('surfaces ambiguous-projection and unresolved block counts honestly', () => {
const out = formatImpactResult(
pdgFindings({
ambiguousProjectionCount: 2,
unresolvedBlockCount: 1,
byDepth: {
1: [
{
depth: 1,
id: 'Function:src/svc.ts:applyDiscount',
name: 'applyDiscount',
type: 'Function',
filePath: 'src/svc.ts',
ambiguous: true,
processes: [],
},
{
depth: 1,
id: null,
name: '(top-level)',
type: 'BasicBlock',
filePath: 'src/svc.ts',
unresolved: true,
processes: [],
},
],
},
byDepthCounts: { 1: 2 },
}),
);
expect(out).toContain('2 block(s) could not be attributed');
expect(out).toContain('1 dependence block(s) map to no owning');
// The shadow / ambiguous rows carry their flags inline.
expect(out).toContain('[ambiguous]');
expect(out).toContain('[unresolved]');
});
it('flags truncation honestly', () => {
const out = formatImpactResult(pdgFindings({ truncated: true, truncatedBy: 'depth' }));
expect(out).toContain('Truncated');
expect(out).toContain('by depth');
expect(out).toContain('deeper PDG impacts may exist');
});
it('renders multiple truncation causes honestly', () => {
const out = formatImpactResult(
pdgFindings({
truncated: true,
truncatedBy: 'depth',
truncatedByReasons: ['depth', 'limit'],
}),
);
expect(out).toContain('Truncated');
expect(out).toContain('by depth, limit');
});
it('renders the degradation note as remediation, not a zero/empty blast radius', () => {
// Shaped like the `_impactImpl` pdgLayer-degradation early return.
const out = formatImpactResult({
mode: 'pdg',
pdgLayer: 'no-layer',
note: 'No PDG layer in this index. Run `gitnexus analyze --pdg` to build it.',
target: { name: 'computeTotal' },
direction: 'downstream',
impactedCount: 0,
risk: 'UNKNOWN',
});
// The remediation guidance is present.
expect(out).toContain('analyze --pdg');
expect(out).toContain('no usable PDG layer');
// It must NOT read as a confident "isolated / no dependencies / safe".
expect(out).not.toContain('isolated');
expect(out).not.toContain('No downstream dependencies found');
});
it('names the missing sub-layer in a partial-degradation note', () => {
const out = formatImpactResult({
mode: 'pdg',
pdgLayer: 'sub-layer-missing',
missingSubLayer: 'REACHING_DEF',
note: 'CDG present but REACHING_DEF absent.',
target: { name: 'computeTotal' },
direction: 'downstream',
impactedCount: 0,
risk: 'UNKNOWN',
});
expect(out).toContain('REACHING_DEF');
expect(out).toContain('analyze --pdg');
expect(out).not.toContain('isolated');
});
it('renders the no-body (KTD6) caveat, not "isolated / no dependencies"', () => {
// Shaped like `_runImpactPDG`'s no-body early return.
const out = formatImpactResult({
mode: 'pdg',
target: {
id: 'Interface:src/types.ts:Card',
name: 'Card',
type: 'Interface',
filePath: 'src/types.ts',
},
direction: 'downstream',
reachableBlocks: [],
blockCount: 0,
truncated: false,
depthReached: 0,
epistemic: 'no-pdg-body',
note:
"'Card' has no PDG body — no BasicBlocks / control- or data-dependence edges exist for " +
'this symbol (e.g. an interface, type alias, abstract/ambient member, or a one-line ' +
'declaration with no CFG). This is NOT a confident "no impact": the local PDG ' +
'statement slice cannot model this symbol kind. Inter-procedural symbol reach may still be attached.',
impactedCount: 0,
risk: 'UNKNOWN',
byDepth: {},
byDepthCounts: { 1: 0 },
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
});
expect(out).toContain('no PDG body');
expect(out.toLowerCase()).toContain('not applicable');
// NOT the false-safe callgraph "isolated" headline. (The note may DISCLAIM
// "no impact", but the confident standalone "appears isolated." sentence
// and the callgraph "No ... dependencies found." headline must be absent.)
expect(out).not.toContain('appears isolated');
expect(out).not.toContain('No downstream dependencies found');
});
it('renders 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: {
id: 'Function:src/svc.ts:noop',
name: 'noop',
type: 'Function',
filePath: 'src/svc.ts',
},
direction: 'downstream',
impactedCount: 0,
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
note:
"'noop' has a PDG body but 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). Inter-procedural symbol reach is attached ' +
'separately by the unified impact dispatcher.',
reachableBlocks: [],
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
depthReached: 1,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
byDepth: {},
byDepthCounts: { 1: 0 },
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
});
expect(out).toContain('no inter-procedural symbols reached');
// 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).toContain('Inter-procedural symbol reach is attached');
});
// ── 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: 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). 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: 0, processes_affected: 0, modules_affected: 0 },
byDepthCounts: {},
affected_processes: [],
affected_modules: [],
byDepth: {},
...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; 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', () => {
const out = formatImpactResult(pdgStatementSlice({ truncated: true, truncatedBy: 'depth' }));
expect(out).toContain('Truncated');
expect(out).toContain('by depth');
});
it('flags truncated empty statement slices honestly', () => {
const out = formatImpactResult(
pdgStatementSlice({
affectedStatements: [],
affectedStatementCount: 0,
truncated: true,
truncatedBy: 'limit',
note: 'Statement slice stopped at the configured result limit.',
}),
);
expect(out).toContain('No statements downstream-dependent on src/svc.ts:8');
expect(out).toContain('Truncated');
expect(out).toContain('by limit');
expect(out).toContain('Statement slice stopped at the configured result limit.');
});
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');
});
it('suppresses callgraph next-step hints for PDG and failed impact results', () => {
expect(getNextStepHint('impact')).toContain('Review d=1 items first');
expect(getNextStepHint('impact', pdgStatementSlice())).toBe('');
expect(getNextStepHint('impact', { mode: 'pdg', pdgLayer: 'no-layer' })).toBe('');
expect(getNextStepHint('impact', { mode: 'pdg', status: 'ambiguous' })).toBe('');
expect(getNextStepHint('impact', { error: 'Target not found' })).toBe('');
});
});
describe('formatImpactResult — callgraph rendering is UNCHANGED (regression guard)', () => {
// A known callgraph result. The exact rendered string is pinned: U5 must not
// perturb the default-mode output by one byte (KTD8 interchangeability).
const callgraphResult = {
target: { kind: 'Function', name: 'computeTotal' },
direction: 'upstream',
impactedCount: 2,
risk: 'MEDIUM',
byDepthCounts: { 1: 1, 2: 1 },
byDepth: {
1: [
{
type: 'Function',
name: 'callerA',
filePath: 'src/a.ts',
relationType: 'CALLS',
confidence: 1,
},
],
2: [
{
type: 'Function',
name: 'callerB',
filePath: 'src/b.ts',
relationType: 'CALLS',
confidence: 0.8,
},
],
},
};
it('renders the callgraph result with the exact pre-U5 text (byte-identical)', () => {
const expected = [
'Blast radius for Function computeTotal (upstream): 2 symbol(s) depends on this (will break if changed)',
'',
'd=1: WILL BREAK (direct) (1)',
' Function callerA → src/a.ts [CALLS]',
'',
'd=2: LIKELY AFFECTED (indirect) (1)',
' Function callerB → src/b.ts [CALLS] (conf: 0.8)',
].join('\n');
expect(formatImpactResult(callgraphResult)).toBe(expected);
});
it('does not apply any PDG framing to a callgraph result', () => {
const out = formatImpactResult(callgraphResult);
expect(out).not.toContain('PDG-dependent symbols');
expect(out).not.toContain('intra-procedural');
expect(out).not.toContain('analyze --pdg');
});
it('renders the callgraph summary-only branch unchanged', () => {
const out = formatImpactResult({
target: { kind: 'Function', name: 'foo' },
direction: 'downstream',
impactedCount: 3,
risk: 'LOW',
byDepthCounts: { 1: 2, 2: 1 },
// no byDepth → summary-only branch
});
expect(out).toContain('(summary only — use summaryOnly: false to see symbol lists)');
expect(out).toContain('d=1: WILL BREAK (direct) (2)');
expect(out).not.toContain('PDG-dependent symbols');
});
it('renders the callgraph isolated / zero case unchanged', () => {
const out = formatImpactResult({
target: { name: 'lonely' },
direction: 'downstream',
impactedCount: 0,
risk: 'LOW',
});
expect(out).toBe('lonely: No downstream dependencies found. This symbol appears isolated.');
});
it('renders the callgraph lower-bound (DI/dynamic-dispatch) copy unchanged', () => {
const out = formatImpactResult({
target: { name: 'viaInterface' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
epistemic: 'lower-bound',
boundaries: ['interface PaymentGateway'],
});
expect(out).toContain('LOWER BOUND');
expect(out).toContain('interface PaymentGateway');
expect(out).not.toContain('PDG');
});
});