From 3d12eab6f99413fc850e63c9cb58bd6ecc6cdbc5 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 16 Jun 2026 08:05:58 +0000 Subject: [PATCH] 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 --- gitnexus/src/core/group/cross-impact.ts | 6 +- gitnexus/src/mcp/local/local-backend.ts | 384 +++++++++++++++++- .../test/integration/impact-pdg-shape.test.ts | 352 ++++++++++++++++ 3 files changed, 724 insertions(+), 18 deletions(-) create mode 100644 gitnexus/test/integration/impact-pdg-shape.test.ts diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index e8db52dd5..0d7e2f360 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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'; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 564071d5d..0d915a24a 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -112,6 +112,306 @@ function fnLineOf(id: string): number { return Number(parts[parts.length - 3]); } +/** + * Parse the `` segment out of a `BasicBlock` id, the COUNTERPART to + * `fnLineOf`. The id template is `BasicBlock::::`, + * so the file path is everything BETWEEN the `BasicBlock:` prefix and the last + * THREE colon-segments (`::`). `` 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::::`: + * - `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(); + 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: }`, 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 { + 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 = items.length > 0 ? { 1: items } : {}; + const byDepthCounts: Record = { 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 { 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, + byDepthCounts: { 1: 0 } as Record, + 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, + byDepthCounts: { 1: 0 } as Record, + 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, + }); } /** diff --git a/gitnexus/test/integration/impact-pdg-shape.test.ts b/gitnexus/test/integration/impact-pdg-shape.test.ts new file mode 100644 index 000000000..9d9411728 --- /dev/null +++ b/gitnexus/test/integration/impact-pdg-shape.test.ts @@ -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(); + 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:::: (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).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).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).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; + }, + }, +);