mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
fa176a3d58
commit
3d12eab6f9
3 changed files with 724 additions and 18 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
352
gitnexus/test/integration/impact-pdg-shape.test.ts
Normal file
352
gitnexus/test/integration/impact-pdg-shape.test.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue