feat(impact): block->symbol projection + parity result, pdg mode live (U4)

projectBlocksToSymbols maps reachable BasicBlocks to owning Function/
Method nodes (startLine = fnLine-1, params bound). Same-line collisions
have no joinable column (no startColumn in schema), so report ALL
colliding symbols (ambiguous-projection), never silent-pick; blocks
with no owner surface under an 'unresolved' marker, never dropped.
Assemble a KTD8 parity result: byDepth collapsed to one bucket,
byDepthCounts, target identical, empty processes/modules, PDG-specific
epistemic, 'UNKNOWN' risk sentinel. mergeRisk exported (no logic change)
to guard that UNKNOWN never coerces to a confident LOW.

Refs U4
This commit is contained in:
Gergo Magyar 2026-06-16 08:05:58 +00:00
parent fa176a3d58
commit 3d12eab6f9
3 changed files with 724 additions and 18 deletions

View file

@ -339,7 +339,11 @@ function extractProcessNames(impact: unknown): string[] {
return o.affected_processes.map((p) => String(p.name ?? '')).filter(Boolean);
}
function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
// Exported so the U4 PDG-result interchangeability contract (KTD8) can assert
// permanently that a PDG `risk:'UNKNOWN'` never coalesces to a confident `LOW`.
// No behavior change — `'UNKNOWN'` was already handled correctly at the
// `(localRisk === 'LOW' || localRisk === 'UNKNOWN')` branch below.
export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
const highConf = cross.some((c) => c.contract.confidence >= 0.85);
if (localRisk === 'CRITICAL') return 'CRITICAL';
if (cross.length >= 3) return 'CRITICAL';

View file

@ -112,6 +112,306 @@ function fnLineOf(id: string): number {
return Number(parts[parts.length - 3]);
}
/**
* Parse the `<filePath>` segment out of a `BasicBlock` id, the COUNTERPART to
* `fnLineOf`. The id template is `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>`,
* so the file path is everything BETWEEN the `BasicBlock:` prefix and the last
* THREE colon-segments (`<fnLine>:<fnCol>:<blockIdx>`). `<filePath>` may itself
* contain `':'` (a Windows drive letter), so we strip from both ends rather than
* split-and-pick. Returns `''` for an unparseable id (treated as unresolved).
*/
function fnFileOf(id: string): string {
const parts = id.split(':');
// Need: `BasicBlock` + filePath(≥1) + fnLine + fnCol + blockIdx ⇒ ≥5 segments.
if (parts.length < 5 || parts[0] !== 'BasicBlock') return '';
// Drop the leading `BasicBlock` token and the trailing fnLine/fnCol/blockIdx,
// rejoin the middle on ':' to restore a path that itself contained colons.
return parts.slice(1, parts.length - 3).join(':');
}
// ── Block → owning-symbol projection types (U4) ──────────────────────────────
/**
* One owning-symbol candidate for a reachable BasicBlock, OR an explicit
* `unresolved` marker for a block that maps to no `Function`/`Method` symbol
* (top-level/free-statement block, or a nested lambda whose start line ≠ any
* symbol `startLine`). A null `id` is the shadow-path marker — the block is
* surfaced under its file, never silently dropped (R9: a silent drop is a hidden
* recall loss).
*/
export interface OwningSymbol {
/** Symbol UID, or `null` for the `unresolved` shadow-path entry. */
id: string | null;
name: string;
/** `'Function' | 'Method' | …`, or `'unresolved'` for the shadow path. */
type: string;
filePath: string;
/** Symbol `startLine` (0-based), present only for a resolved symbol. */
startLine?: number;
/**
* True when this block's `(filePath, startLine)` query matched >1 symbol —
* same-line, different-name functions that the schema cannot disambiguate
* (no `startColumn` column; Feasibility Finding 1). ALL colliding symbols are
* reported (never a silent pick), each carrying this flag.
*/
ambiguous?: boolean;
}
/**
* Net-new block → owning-symbol resolver (U4) — the REVERSE of
* `resolveBlockAnchor` (which goes symbol→blocks). No precedent exists:
* `_pdgQueryImpl` only ever extracts a raw `functionLine`, never an owning
* symbol. Built as a module-scope function taking injected deps (KTD2
* extraction discipline) so it can move to a future `pdg-impact.ts` engine as a
* MOVE, not a rewrite.
*
* For each reachable block id `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>`:
* - `fnLineOf` → 1-based function start line; `fnFileOf` → file path.
* - Query `Function`/`Method` `WHERE filePath = $f AND startLine = (fnLine-1)`
* — block `fnLine` is 1-based, symbol `startLine` is 0-based, so subtract one
* (the `[symStart+1]` convention from `resolveBlockAnchor`, applied in
* reverse; NOT re-derived).
*
* Two non-happy paths, BOTH surfaced (never silent):
* - **>1 match** (same-line different-name functions): `fnCol` rides the block
* id but the schema has NO `startColumn` column and the symbol id encodes only
* the name, so a `(filePath, startLine)` join cannot disambiguate. Report ALL
* colliding symbols, each `ambiguous: true` (R4 / Feasibility Finding 1).
* - **0 matches** (top-level/free-statement block, or a lambda whose start line
* ≠ a symbol `startLine`): one `unresolved` entry (`id: null`) under the
* block's file (R9 shadow path).
*
* Distinct `(filePath, fnLine)` pairs are queried once each (a block and its
* siblings in the same function share a pair), so the cost is O(distinct
* functions), not O(blocks).
*/
async function projectBlocksToSymbols(deps: {
lbugPath: string;
blockIds: string[];
executeParameterized: typeof executeParameterized;
}): Promise<{ symbols: OwningSymbol[]; unresolvedCount: number; ambiguousCount: number }> {
const { lbugPath, blockIds, executeParameterized: exec } = deps;
// Group blocks by their (filePath, fnLine) owning-function key so each owning
// function is resolved with a single query regardless of block count.
const byFnKey = new Map<string, { filePath: string; symStart: number }>();
for (const id of blockIds) {
const filePath = fnFileOf(id);
const fnLine = fnLineOf(id); // 1-based
if (!filePath || !Number.isFinite(fnLine)) {
// Unparseable block id — record an unresolved key so it is reported, never
// dropped. Use the raw id as the key so duplicates collapse.
byFnKey.set(`#bad#${id}`, { filePath: filePath || id, symStart: NaN });
continue;
}
const symStart = fnLine - 1; // 0-based symbol startLine (reverse [symStart+1])
byFnKey.set(`${filePath}#${symStart}`, { filePath, symStart });
}
const resolved: OwningSymbol[] = [];
let unresolvedCount = 0;
let ambiguousCount = 0;
await Promise.all(
Array.from(byFnKey.values()).map(async ({ filePath, symStart }) => {
if (!Number.isFinite(symStart)) {
// Unparseable id — shadow-path unresolved under (best-effort) file.
resolved.push({ id: null, name: '(unresolved)', type: 'unresolved', filePath });
unresolvedCount += 1;
return;
}
// `Function`/`Method` carry name+filePath+startLine; the schema has NO
// `startColumn`, so the join is on (filePath, startLine) only. `filePath`
// and `symStart` are BOUND as params (KTD11 — never interpolated). A
// UNION ALL across the two explicit labels is used rather than a
// `(s:Function OR s:Method)` disjunction (unsupported in the LadybugDB
// Cypher subset — the established cross-label pattern, see
// `enrichCandidateLabels`). `LIMIT` is a small validated int literal.
const rows = await exec(
lbugPath,
`MATCH (s:\`Function\`)
WHERE s.filePath = $filePath AND s.startLine = $symStart
RETURN s.id AS id, s.name AS name, 'Function' AS label, s.startLine AS startLine
UNION ALL
MATCH (s:\`Method\`)
WHERE s.filePath = $filePath AND s.startLine = $symStart
RETURN s.id AS id, s.name AS name, 'Method' AS label, s.startLine AS startLine
LIMIT 8`,
{ filePath, symStart },
).catch(() => [] as any[]);
if (rows.length === 0) {
// No owning symbol — top-level/free-statement block or a lambda whose
// start line ≠ a symbol startLine. Shadow path: report under its file.
resolved.push({
id: null,
name: '(unresolved)',
type: 'unresolved',
filePath,
startLine: symStart,
});
unresolvedCount += 1;
return;
}
// >1 ⇒ ambiguous-projection (same-line, different-name functions). Report
// ALL colliding symbols, NEVER silently pick one (R4 / Feasibility 1).
const isAmbiguous = rows.length > 1;
for (const r of rows) {
resolved.push({
id: String((r as any).id ?? (r as any)[0] ?? ''),
name: String((r as any).name ?? (r as any)[1] ?? ''),
type: String((r as any).label ?? (r as any)[2] ?? 'Function'),
filePath,
startLine: Number((r as any).startLine ?? (r as any)[3] ?? symStart),
...(isAmbiguous ? { ambiguous: true as const } : {}),
});
}
if (isAmbiguous) ambiguousCount += 1;
}),
);
// Deterministic order: by filePath, then startLine, then id (unresolved last
// within a file). Order-independence matters for the parity/fingerprint
// contract (KTD8 standing interchangeability) and for stable consumer output.
resolved.sort((a, b) => {
if (a.filePath !== b.filePath) return a.filePath < b.filePath ? -1 : 1;
const al = a.startLine ?? Number.MAX_SAFE_INTEGER;
const bl = b.startLine ?? Number.MAX_SAFE_INTEGER;
if (al !== bl) return al - bl;
const ai = a.id ?? '￿';
const bi = b.id ?? '￿';
return ai < bi ? -1 : ai > bi ? 1 : 0;
});
return { symbols: resolved, unresolvedCount, ambiguousCount };
}
/**
* Assemble the consumer-safe PDG impact result (U4 / KTD8 parity matrix).
*
* Takes the U3 traversal output (reachable block set + truncation signalling)
* plus the U4 block→symbol projection, and shapes a result STRUCTURALLY
* substitutable for the call-graph `_runImpactBFS` result so every consumer
* (CLI `formatImpactResult`, group `collectImpactSymbolUids`/`mergeRisk`,
* `impactByUid`) renders it without misrendering. This is a STANDING
* interchangeability contract, not a one-time check.
*
* Field-by-field vs the call-graph result (KTD8):
* - `target.id/name/type/filePath` — identical shape (`collectImpactSymbolUids`
* keys on `target.id`/`target.filePath`).
* - `byDepth` — same `{ [depth]: item[] }` map shape, but COLLAPSED to a single
* bucket (`1`): intra-procedural dependence has no meaningful inter-symbol hop
* count (block-hops are NOT call-hops). Items carry `{ id, name, type,
* filePath, … }` exactly like the call-graph items so `collectImpactSymbolUids`
* collects their UIDs. `unresolved` shadow-path entries keep `id: null` (they
* are surfaced, never dropped — but collect as no UID).
* - `byDepthCounts` — `{ 1: <symbolCount> }`, same shape.
* - `affected_processes` / `affected_modules` — empty `[]` (no
* STEP_IN_PROCESS/module edges originate from BasicBlocks; consumers coalesce
* `[]` safely).
* - `epistemic` — a PDG-specific marker (`'pdg-intra-procedural'`), NOT the
* callgraph DI/dynamic-dispatch `'lower-bound'` copy. `note` carries the
* PDG framing so the CLI prints PDG text, not callgraph boundary text.
* - `risk` — the existing `'UNKNOWN'` sentinel (NOT a new label). `mergeRisk`
* already coalesces `'UNKNOWN'` correctly (never a confident `LOW`).
* - `impactedCount` — count of DISTINCT owning SYMBOLS (resolved UIDs), the
* meaningful unit for the impact question ("which symbols are affected").
* `blockCount` is retained separately as the raw reachable-block count.
*/
function assemblePdgImpactResult(input: {
target: { id: string; name: string; type: string; filePath: string };
direction: 'upstream' | 'downstream';
reachableBlocks: string[];
projection: { symbols: OwningSymbol[]; unresolvedCount: number; ambiguousCount: number };
depthReached: number;
truncated: boolean;
truncatedBy?: 'depth' | 'limit';
}): Record<string, unknown> {
const { target, direction, reachableBlocks, projection } = input;
const { symbols, unresolvedCount, ambiguousCount } = projection;
// Items for the single collapsed bucket. Shaped like the call-graph byDepth
// items (`{ depth, id, name, type, filePath, processes }`) so consumers that
// iterate byDepth read the same fields. `unresolved` entries keep `id: null`
// (surfaced under their file; `collectImpactSymbolUids` skips a null id, which
// is correct — there is no symbol UID to attribute).
const items = symbols.map((s) => ({
depth: 1,
id: s.id,
name: s.name,
type: s.type,
filePath: s.filePath,
...(s.startLine !== undefined ? { startLine: s.startLine } : {}),
...(s.ambiguous ? { ambiguous: true } : {}),
...(s.id === null ? { unresolved: true } : {}),
processes: [] as unknown[],
}));
// impactedCount = distinct owning SYMBOLS (resolved UIDs). Unresolved shadow
// entries are surfaced in byDepth but do NOT inflate the symbol count.
const resolvedUids = new Set(symbols.filter((s) => s.id !== null).map((s) => s.id as string));
const impactedCount = resolvedUids.size;
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.`,
];
if (ambiguousCount > 0) {
noteParts.push(
`${ambiguousCount} owning-symbol ${ambiguousCount === 1 ? 'projection is' : 'projections are'} ` +
`ambiguous: same-line functions cannot be disambiguated by start line alone (no startColumn ` +
`in the schema), so ALL colliding symbols are reported — none is silently picked.`,
);
}
if (unresolvedCount > 0) {
noteParts.push(
`${unresolvedCount} reachable ${unresolvedCount === 1 ? 'block maps' : 'blocks map'} to no ` +
`owning Function/Method (top-level statement or a lambda whose start line is not a symbol ` +
`start) — surfaced under their file as 'unresolved', never dropped.`,
);
}
return {
mode: 'pdg',
target,
direction,
impactedCount,
// KTD8: reuse the existing UNKNOWN sentinel — never a confident LOW (which
// would read as "safe to refactor"; #2129/#1858 false-safe lineage). PDG
// mode is intra-procedural, so its count is a per-function lower bound on the
// true blast radius and risk is genuinely UNKNOWN at the program level.
risk: 'UNKNOWN',
// PDG-specific epistemic marker — NOT the callgraph 'lower-bound'/DI copy.
epistemic: 'pdg-intra-procedural',
note: noteParts.join(' '),
// Raw block-level detail retained alongside the symbol projection (U3 tests
// and the accuracy harness read these).
reachableBlocks,
blockCount: reachableBlocks.length,
depthReached: input.depthReached,
unresolvedBlockCount: unresolvedCount,
ambiguousProjectionCount: ambiguousCount,
...(input.truncated ? { truncated: true } : {}),
...(input.truncatedBy ? { truncatedBy: input.truncatedBy } : {}),
summary: {
direct: impactedCount,
processes_affected: 0,
modules_affected: 0,
},
byDepthCounts,
affected_processes: [] as unknown[],
affected_modules: [] as unknown[],
byDepth,
};
}
/** The two impact engines (KTD1). `'callgraph'` is the default/established path. */
export type ImpactMode = 'callgraph' | 'pdg';
@ -4833,7 +5133,14 @@ export class LocalBackend {
executeParameterized: typeof executeParameterized;
}): Promise<any> {
const { repo, sym, direction, maxDepth, executeParameterized: exec } = deps;
const target = { name: sym.name, id: sym.id, filePath: sym.filePath };
// `target` carries the call-graph-compatible shape (id/name/type/filePath) so
// `collectImpactSymbolUids` keys on it identically to a callgraph result.
const target = {
id: sym.id,
name: sym.name,
type: deps.symType || 'Function',
filePath: sym.filePath,
};
// Validate the per-step LIMIT as a positive integer (KTD11 — interpolated,
// so it must be sanitised, never user-string-passed). A non-integer / out-of
@ -4893,6 +5200,16 @@ export class LocalBackend {
`Use mode:'callgraph' for its inter-procedural blast radius.`,
impactedCount: 0,
risk: 'UNKNOWN',
// KTD8 parity fields so a consumer iterating byDepth / reading the
// depth counts on a no-body result still finds a well-formed (empty)
// shape rather than `undefined` (which would render as "isolated").
byDepth: {} as Record<number, unknown[]>,
byDepthCounts: { 1: 0 } as Record<number, number>,
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [] as unknown[],
affected_modules: [] as unknown[],
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
};
}
@ -4951,26 +5268,59 @@ export class LocalBackend {
const reachableBlocks = [...reachable].sort();
const truncated = truncatedByDepth || truncatedByLimit;
const truncatedBy: 'depth' | 'limit' | undefined = truncatedByDepth
? 'depth'
: truncatedByLimit
? 'limit'
: undefined;
return {
mode: 'pdg',
target,
// ── 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).
if (reachableBlocks.length === 0) {
return {
mode: 'pdg',
target,
direction,
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.`,
reachableBlocks: [] as string[],
blockCount: 0,
depthReached,
unresolvedBlockCount: 0,
ambiguousProjectionCount: 0,
...(truncated ? { truncated: true } : {}),
...(truncatedBy ? { truncatedBy } : {}),
byDepth: {} as Record<number, unknown[]>,
byDepthCounts: { 1: 0 } as Record<number, number>,
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [] as unknown[],
affected_modules: [] as unknown[],
};
}
// ── U4: project reachable blocks → owning symbols, assemble parity result ──
const projection = await projectBlocksToSymbols({
lbugPath: repo.lbugPath,
blockIds: reachableBlocks,
executeParameterized: exec,
});
return assemblePdgImpactResult({
target: { id: sym.id, name: sym.name, type: deps.symType || 'Function', filePath: sym.filePath },
direction,
reachableBlocks,
blockCount: reachableBlocks.length,
projection,
depthReached,
...(truncated ? { truncated: true } : {}),
...(truncatedByDepth ? { truncatedBy: 'depth' as const } : {}),
...(truncatedByLimit && !truncatedByDepth ? { truncatedBy: 'limit' as const } : {}),
// U4 reshapes this into the consumer-safe impact result (block→symbol
// projection, byDepth/byDepthCounts, risk, parity matrix). U3 stops at the
// reachable block set + truncation signalling.
note:
reachableBlocks.length === 0
? `'${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".`
: undefined,
};
truncated,
truncatedBy,
});
}
/**

View file

@ -0,0 +1,352 @@
/**
* Integration Tests: `impact` PDG-mode RESULT SHAPE + consumer-safety (U4 / KTD8)
*
* This suite guards the **standing interchangeability contract** (KTD8): a
* `mode:'pdg'` result must be structurally substitutable for the call-graph
* result for EVERY consumer (CLI `formatImpactResult`, group
* `collectImpactSymbolUids`/`mergeRisk`, `impactByUid`). These are not one-time
* checks — they protect a permanent contract. If a future change drops `target.id`,
* un-collapses `byDepth`, or mints a non-`UNKNOWN` risk, a consumer misrenders and
* one of these tests must go red.
*
* It also exercises the **net-new block→owning-symbol resolver** (the reverse of
* `resolveBlockAnchor`, no precedent) including the two non-happy paths the
* Feasibility review surfaced:
* - same-line, different-name functions → ambiguous-projection (report ALL,
* never silently pick one — there is no `startColumn` to disambiguate), and
* - a reachable block that owns no symbol (top-level / free statement) →
* reported under its file as `unresolved`, NEVER silently dropped (R9).
*
* ── Fixture graph (hand-seeded, no parser; controlled line numbers) ──────────
* One file `src/flow.ts`.
* - `target` fn at 0-based [10,10] ⇒ anchor window [11,11], seed block S@11.
* - `up` fn at [4,4] ⇒ block P@6 (RD def + CDG controller into S).
* - `down` fn at [19,21] ⇒ blocks D1@21, D2@22 (RD uses, downstream of S).
* - `ctl` fn at [29,31] ⇒ blocks K1@31, K2@32 (CDG dependents of S).
* - `dupA` AND `dupB`, BOTH at 0-based [40,42] (SAME (filePath,startLine)) —
* a CDG-reachable block T@41 maps to BOTH (ambiguous-projection).
* - a free/top-level block U@99 owned by NO Function (downstream of K2) — the
* `unresolved` shadow path.
*
* Downstream from S: RD → {D1,D2}; CDG → {K1,K2} → T(@41) → U(@99 top-level).
*
* `loadMeta` is mocked to stamp BOTH caps so `pdgLayerStatus` returns `ready`.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import type { RepoMeta } from '../../src/storage/repo-manager.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import {
collectImpactSymbolUids,
mergeRisk,
} from '../../src/core/group/cross-impact.js';
import type { CrossRepoImpact } from '../../src/core/group/types.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
// Both caps stamped ⇒ pdgLayerStatus === 'ready' ⇒ traversal + projection run.
loadMeta: vi.fn().mockResolvedValue({
pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 },
} as unknown as RepoMeta),
};
});
const F = 'src/flow.ts';
// Block ids: BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx> (fnLine 1-based)
const S = `BasicBlock:${F}:11:0:0`; // target@[10,10]
const P = `BasicBlock:${F}:5:0:0`; // up@[4,4]
const D1 = `BasicBlock:${F}:20:0:0`; // down@[19,21]
const D2 = `BasicBlock:${F}:20:0:1`; // down@[19,21]
const K1 = `BasicBlock:${F}:30:0:0`; // ctl@[29,31]
const K2 = `BasicBlock:${F}:30:0:1`; // ctl@[29,31]
const T = `BasicBlock:${F}:41:0:0`; // dupA AND dupB BOTH @[40,42] → ambiguous
const U = `BasicBlock:${F}:99:0:0`; // top-level / no owning symbol → unresolved
withTestLbugDB(
'impact-pdg-shape',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized in afterSetup');
backend = ext._backend;
});
// Deep enough to traverse the full CDG chain S→K1→K2→T(@dup)→U(top-level),
// so the ambiguous-projection and unresolved shadow-path blocks are reached.
const downstream = () =>
backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
maxDepth: 10,
});
// ── The net-new block → owning-symbol resolver ────────────────────────────
describe('block → owning-symbol projection (KTD8 net-new resolver)', () => {
it('maps a known reachable block to its owning function (0-based offset correct)', async () => {
const result = await downstream();
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
// D1/D2 (fnLine 20 ⇒ symbol startLine 19) own `down`; K1/K2 (fnLine 30 ⇒
// startLine 29) own `ctl`. The resolver must surface BOTH owning fns.
const items = Object.values(result.byDepth as Record<number, any[]>).flat();
const names = new Set(items.map((i: any) => i.name));
expect(names.has('down')).toBe(true);
expect(names.has('ctl')).toBe(true);
// And the resolved owning symbols carry real UIDs (not null).
const down = items.find((i: any) => i.name === 'down');
expect(down.id).toBe('func:down');
expect(down.filePath).toBe(F);
});
it('a reachable block owning NO symbol is reported as unresolved, never dropped (R9 shadow path)', async () => {
const result = await downstream();
const items = Object.values(result.byDepth as Record<number, any[]>).flat();
// U@99 has no owning Function/Method → an explicit unresolved entry.
const unresolved = items.filter((i: any) => i.id === null || i.type === 'unresolved');
expect(unresolved.length).toBeGreaterThanOrEqual(1);
expect(unresolved[0].filePath).toBe(F);
// It is surfaced (recall preserved), and the top-level count is exposed.
expect(result.unresolvedBlockCount).toBeGreaterThanOrEqual(1);
// But an unresolved block contributes NO symbol UID (no false attribution).
expect(unresolved.every((i: any) => i.id === null)).toBe(true);
});
it('two functions sharing (filePath, startLine) project to BOTH, never a silent pick (Feasibility Finding 1)', async () => {
const result = await downstream();
const items = Object.values(result.byDepth as Record<number, any[]>).flat();
// T@41 ⇒ startLine 40 matches BOTH func:dupA and func:dupB (they share the
// NAME 'dupTarget' and the same start line). The resolver must report
// BOTH (ambiguous-projection), each flagged, never one of them. Identity
// is the UID, not the shared name.
const dupItems = items.filter((i: any) => i.id === 'func:dupA' || i.id === 'func:dupB');
const dupIds = new Set(dupItems.map((i: any) => i.id));
expect(dupIds.has('func:dupA')).toBe(true);
expect(dupIds.has('func:dupB')).toBe(true);
expect(dupItems.every((i: any) => i.ambiguous === true)).toBe(true);
// Both share the same projected name (the schema can't disambiguate).
expect(dupItems.every((i: any) => i.name === 'dupTarget')).toBe(true);
expect(result.ambiguousProjectionCount).toBeGreaterThanOrEqual(1);
// The PDG note must call out the ambiguity, not hide it.
expect(result.note).toMatch(/ambiguous|same-line/i);
});
});
// ── KTD8 result-shape parity matrix (vs the call-graph result) ────────────
describe('result-shape parity (KTD8 standing interchangeability contract)', () => {
it('populates target.id / target.filePath with call-graph-compatible shape', async () => {
const result = await downstream();
expect(result.target).toBeDefined();
expect(result.target.id).toBe('func:target');
expect(result.target.name).toBe('target');
expect(result.target.filePath).toBe(F);
// `type` present like the callgraph target (consumers may read it).
expect(typeof result.target.type).toBe('string');
});
it('populates byDepth (single collapsed bucket) and byDepthCounts in callgraph shape', async () => {
const result = await downstream();
// byDepth is a { [depth]: item[] } map, collapsed to exactly ONE bucket
// — block-hops are not call-hops, so there is no multi-depth fan.
const depths = Object.keys(result.byDepth);
expect(depths).toEqual(['1']);
expect(Array.isArray(result.byDepth['1'])).toBe(true);
// Each item carries the call-graph item fields consumers iterate on.
for (const it of result.byDepth['1']) {
expect(it).toHaveProperty('id');
expect(it).toHaveProperty('name');
expect(it).toHaveProperty('filePath');
expect(it).toHaveProperty('processes'); // shape-stable like callgraph
}
// byDepthCounts mirrors the bucket.
expect(result.byDepthCounts['1']).toBe(result.byDepth['1'].length);
});
it('affected_processes / affected_modules are empty arrays (consumers coalesce []) ', async () => {
const result = await downstream();
expect(result.affected_processes).toEqual([]);
expect(result.affected_modules).toEqual([]);
expect(result.summary.processes_affected).toBe(0);
expect(result.summary.modules_affected).toBe(0);
});
it("epistemic/note is PDG-specific, NOT the callgraph DI/dynamic-dispatch copy", async () => {
const result = await downstream();
// PDG marker, not the callgraph 'lower-bound'/'exact'.
expect(result.epistemic).toBe('pdg-intra-procedural');
// Note frames the intra-procedural caveat, never the DI/interface text.
expect(result.note).toMatch(/intra-procedural|dependence/i);
expect(result.note).not.toMatch(/DI container|dynamic dispatch/i);
});
it("risk is the existing 'UNKNOWN' sentinel, not a minted PDG label", async () => {
const result = await downstream();
expect(result.risk).toBe('UNKNOWN');
// impactedCount = distinct owning SYMBOLS (down, ctl, dupA, dupB) — the
// meaningful unit; unresolved blocks do not inflate it.
expect(result.impactedCount).toBe(4);
// blockCount is the raw reachable-block count, retained separately.
expect(result.blockCount).toBeGreaterThanOrEqual(result.impactedCount);
});
});
// ── 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 () => {
const result = await downstream();
const { uids, targetFilePath } = collectImpactSymbolUids(result, undefined);
// The target plus every resolved owning symbol's UID is collected; the
// null unresolved entry contributes nothing (no crash, no '' UID).
expect(uids.length).toBeGreaterThan(0);
expect(uids).toContain('func:target'); // from target.id
expect(uids).toContain('func:down');
expect(uids).toContain('func:ctl');
expect(uids).not.toContain('null');
expect(uids).not.toContain('');
expect(targetFilePath).toBe(F);
});
it("mergeRisk does NOT render a confident LOW from a PDG 'UNKNOWN' risk (R7 false-LOW trap)", async () => {
const result = await downstream();
const localRisk = String(result.risk);
expect(localRisk).toBe('UNKNOWN');
// No cross-repo hits → mergeRisk returns localRisk verbatim: 'UNKNOWN',
// NEVER coerced to a confident 'LOW' (the false-safe this guards).
expect(mergeRisk(localRisk, [])).toBe('UNKNOWN');
// With a cross-repo hit, 'UNKNOWN' bumps UP to 'MEDIUM' (never down to LOW).
const cross = [
{ contract: { confidence: 0.5 } },
] as unknown as CrossRepoImpact[];
expect(mergeRisk(localRisk, cross)).toBe('MEDIUM');
});
});
// ── KTD5 ambiguous trap: pdg+ambiguous never runs the callgraph fan-out ───
describe('KTD5 ambiguous target never invokes the callgraph BFS', () => {
it("mode:'pdg' on an ambiguous target returns candidates, never calls _runImpactBFS", async () => {
// Spy on the private callgraph BFS; if the pdg ambiguous path leaked into
// the callgraph fan-out, this spy would fire (the silent-fallback KTD5
// forbids). `dupTarget` collides across dupA/dupB by NAME.
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
try {
const result = await backend.callTool('impact', {
target: 'dupTarget',
direction: 'downstream',
mode: 'pdg',
});
expect(result.status).toBe('ambiguous');
expect(result.mode).toBe('pdg');
// No callgraph engine ran under the pdg call.
expect(bfsSpy).not.toHaveBeenCalled();
// And it surfaces the candidate list (no silent zero blast radius).
expect(Array.isArray(result.candidates)).toBe(true);
expect(result.candidates.length).toBeGreaterThanOrEqual(2);
// The response never names pdg_query (toolName union widened to impact).
expect(JSON.stringify(result)).not.toMatch(/pdg_query/);
} finally {
bfsSpy.mockRestore();
}
});
});
// ── No-body symbol parity (KTD6 × KTD8) ───────────────────────────────────
describe('no-body symbol still yields a parity-shaped (non-LOW) result', () => {
it('an interface (no CFG body) returns the no-body note + well-formed empty shape', async () => {
const result = await backend.callTool('impact', {
target: 'IShape',
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
expect(result.risk).not.toBe('LOW'); // never a confident "safe to refactor"
expect(result.note).toMatch(/no.*(body|block|dependence)/i);
// Parity fields are present & well-formed (empty), not undefined.
expect(result.byDepth).toEqual({});
expect(result.byDepthCounts['1']).toBe(0);
expect(result.affected_processes).toEqual([]);
expect(result.affected_modules).toEqual([]);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const fn = (
id: string,
name: string,
startLine: number,
endLine: number,
type: 'Function' | 'Interface' = 'Function',
) =>
adapter.executePrepared(
`CREATE (n:${type} {id: $id, name: $name, filePath: $filePath, startLine: $startLine, endLine: $endLine, isExported: true, content: 'x', description: 'shape fixture'})`,
{ id, name, filePath: F, startLine, endLine },
);
const block = (id: string, startLine: number, text: string) =>
adapter.executePrepared(
`CREATE (b:BasicBlock {id: $id, filePath: $filePath, startLine: $startLine, endLine: $startLine, text: $text})`,
{ id, filePath: F, startLine, text },
);
const edge = (type: 'CDG' | 'REACHING_DEF', src: string, dst: string, reason: string) =>
adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: '${type}', confidence: 1.0, reason: $reason, step: 0}]->(b)`,
{ src, dst, reason },
);
// Functions (0-based symbol lines).
await fn('func:target', 'target', 10, 10); // window [11,11] ⇒ seed {S}
await fn('func:up', 'up', 4, 4); // P@6
await fn('func:down', 'down', 19, 21); // D1@21,D2@22
await fn('func:ctl', 'ctl', 29, 31); // K1@31,K2@32
// Same-line collision: dupA and dupB BOTH start at 0-based line 40.
await fn('func:dupA', 'dupTarget', 40, 42);
await fn('func:dupB', 'dupTarget', 40, 42);
// No-body interface (no blocks).
await fn('func:IShape', 'IShape', 50, 52, 'Interface');
// Blocks.
await block(S, 11, 'const x = compute();');
await block(P, 6, 'const seed = input();');
await block(D1, 21, 'use(x);');
await block(D2, 22, 'log(x);');
await block(K1, 31, 'doA();');
await block(K2, 32, 'doB();');
await block(T, 41, 'dispatch();'); // owned by BOTH dupA & dupB
await block(U, 99, 'top-level-side-effect();'); // owned by NO symbol
// RD chain (def→use): P → S → D1 → D2
await edge('REACHING_DEF', P, S, 'seed');
await edge('REACHING_DEF', S, D1, 'x');
await edge('REACHING_DEF', D1, D2, 'x');
// CDG chain: P(controller) → S → K1 → K2 → T(@dup line) → U(top-level)
await edge('CDG', P, S, 'T');
await edge('CDG', S, K1, 'T');
await edge('CDG', K1, K2, 'T');
await edge('CDG', K2, T, 'T');
await edge('CDG', T, U, 'T');
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'shape-repo',
path: '/shape/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'shape123',
stats: { files: 1, nodes: 16, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);