feat(impact): PDG blast-radius traversal core (U3)

_runImpactPDG resolves the target to BasicBlocks (resolveBlockAnchor,
toolName union widened to 'impact') and runs a direction-aware bounded
BFS over CDG+REACHING_DEF: downstream = forward on both edge types,
upstream = reverse on both, unified in one anchored r.type IN [...]
query so the sense never diverges. Anchored + param-bound + LIMIT-
validated (KTD11). No-body symbols return a distinct KTD6 note (never a
confident zero); dual depth/limit truncation flags. fnLineOf extracted
to module scope; pdg_query byte-identical. Test-first per the plan.

Refs U3
This commit is contained in:
Gergo Magyar 2026-06-16 07:55:40 +00:00
parent f85a066d05
commit fa176a3d58
4 changed files with 545 additions and 22 deletions

View file

@ -97,6 +97,21 @@ function resolveAliasString(canonical: unknown, legacy: unknown): string | undef
return undefined;
}
/**
* Parse the `<fnLine>` segment out of a `BasicBlock` id (1-based function start
* line). The id template is
* `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>`
* and `<filePath>` may itself contain `':'` (a Windows drive letter), so the
* segments are taken from the RIGHT: `<blockIdx>` is last, `<fnCol>` second-last,
* `<fnLine>` third-last. Extracted from the `_pdgQueryImpl` closure (#2086) into
* a shared module-scope helper so the PDG impact traversal (U3/U4) reuses the
* exact same parse — the `pdg_query` read path is byte-identical to before.
*/
function fnLineOf(id: string): number {
const parts = id.split(':');
return Number(parts[parts.length - 3]);
}
/** The two impact engines (KTD1). `'callgraph'` is the default/established path. */
export type ImpactMode = 'callgraph' | 'pdg';
@ -3017,7 +3032,7 @@ export class LocalBackend {
private async resolveBlockAnchor(
repo: RepoHandle,
target: string,
toolName: 'explain' | 'pdg_query',
toolName: 'explain' | 'pdg_query' | 'impact',
): Promise<{
anchorClause: string;
queryParams: Record<string, unknown>;
@ -3541,12 +3556,7 @@ export class LocalBackend {
}
// basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` — split
// from the RIGHT (filePath may contain ':').
const fnLineOf = (id: string): number => {
const parts = id.split(':');
return Number(parts[parts.length - 3]);
};
// from the RIGHT (filePath may contain ':'). Shared module-scope `fnLineOf`.
const results =
mode === 'controls'
? rows.map((r: any) => {
@ -4776,6 +4786,41 @@ export class LocalBackend {
* The stub does not yet consume `executeParameterized` — the U3 anchor /
* BFS / projection code joins here.
*/
/**
* U3 — the PDG blast-radius TRAVERSAL (KTD2, KTD4, KTD6, KTD11).
*
* Resolve the target symbol to its seed BasicBlocks, then run a direction-aware
* bounded BFS over `CDG` + `REACHING_DEF` block edges, returning the reachable
* block set with truncation signalling. Block→owning-symbol projection and the
* final impact-shaped result are U4 — this returns a PROVISIONAL payload
* exposing the reachable blocks for U4 to reshape:
* { mode:'pdg', target, direction, reachableBlocks, blockCount,
* truncated, depthReached, note? }
*
* ── KTD4 direction × edge-type truth table (the correctness keystone) ───────
* The combined CDG+RD frontier traverses the SAME sense for BOTH edge types
* under one `direction` label (mixing forward on one and reverse on the other
* is a silent correctness bug):
* downstream — FORWARD on both: from a frontier block `a`, follow edges
* `(a)-[CDG|REACHING_DEF]->(b)` and collect `b`. RD def→use
* (where the def's value flows); CDG controller→dependent (what
* this block controls). "What does changing this affect?"
* upstream — REVERSE on both: from a frontier block `b`, follow edges
* `(a)-[CDG|REACHING_DEF]->(b)` and collect `a`. RD: the defs
* reaching this block's uses; CDG: the blocks controlling it.
* "What does this depend on?"
*
* ── KTD11 LadybugDB constraints ────────────────────────────────────────────
* Every step is ANCHORED on exact BasicBlock ids (`<endpoint>.id IN $frontier`
* — the tightest possible anchor, bound as a param, never interpolated),
* DEPTH-bounded (≤ `maxDepth` BFS rounds), and `LIMIT`-bounded via a validated
* integer interpolation (`LIMIT` cannot be parameterized in LadybugDB). The
* `(a:BasicBlock)-[..]->(b:BasicBlock)` label pair keeps every query on the
* sparse BasicBlock→BasicBlock partition, never a symbol-space scan.
*
* Deps are injected (KTD2 extraction discipline) so this can later move to a
* standalone `pdg-impact.ts` engine as a move, not a rewrite.
*/
private async _runImpactPDG(deps: {
repo: RepoHandle;
sym: { id: string; name: string; filePath: string };
@ -4787,13 +4832,144 @@ export class LocalBackend {
summaryOnly?: boolean;
executeParameterized: typeof executeParameterized;
}): Promise<any> {
const { repo, sym, direction, maxDepth, executeParameterized: exec } = deps;
const target = { name: sym.name, id: sym.id, 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
// range value (NaN, 1.5, negative, huge) is CLAMPED to the bounded default
// rather than rejected: impact's `limit` is a soft page hint, and a clamp
// keeps the safety tool producing a (flagged-bounded) radius instead of a
// hard error. The clamp ceiling matches `pdg_query`'s validated max.
const rawLimit = deps.limit;
const stepLimit =
Number.isInteger(rawLimit) && rawLimit >= 1 && rawLimit <= PDG_QUERY_MAX_LIMIT
? rawLimit
: PDG_QUERY_DEFAULT_LIMIT;
// Depth: clamp to a sane positive integer (the caller default is 3).
const depthBudget =
Number.isInteger(maxDepth) && maxDepth >= 1 ? maxDepth : 3;
// ── Seed: resolve the target symbol to its BasicBlocks (KTD2 reuse) ───────
// resolveBlockAnchor maps the symbol to its block id-prefix + the corrected
// [symStart+1, symEnd+1] line window (do NOT re-derive the 1-based-block /
// 0-based-symbol offset — the helper owns it). `'impact'` toolName widening
// makes any ambiguous-anchor message name THIS tool.
const resolved = await this.resolveBlockAnchor(repo, sym.name, 'impact');
if (resolved.early) return resolved.early;
const { anchorClause, queryParams } = resolved;
const seedRows = await exec(
repo.lbugPath,
`MATCH (a:BasicBlock) WHERE ${anchorClause} RETURN a.id AS id LIMIT ${stepLimit}`,
queryParams,
);
const seedBlocks: string[] = seedRows
.map((r: any) => String(r.id ?? r[0] ?? ''))
.filter((id: string) => id.length > 0);
// ── KTD6 no-body contract: distinguish "no PDG body" from "no dependence" ──
// A symbol that resolves but produces ZERO anchored blocks has no CFG body
// (interface / type alias / abstract / ambient / one-line const). A bare
// impactedCount:0 / risk:'LOW' would read as "safe to refactor" — the exact
// false-safe `impact` exists to prevent (#2129/#1858). Surface an explicit
// note + a non-LOW epistemic marker, never a silent confident zero.
if (seedBlocks.length === 0) {
return {
mode: 'pdg',
target,
direction,
reachableBlocks: [],
blockCount: 0,
truncated: false,
depthReached: 0,
// "No PDG body" — structurally no CFG/blocks for this symbol kind.
epistemic: 'no-pdg-body',
note:
`'${sym.name}' has no PDG body — no BasicBlocks / control- or data-dependence ` +
`edges exist for this symbol (e.g. an interface, type alias, abstract/ambient ` +
`member, or a one-line declaration with no CFG). This is NOT a confident ` +
`"no impact": the intra-procedural PDG mode cannot model this symbol kind. ` +
`Use mode:'callgraph' for its inter-procedural blast radius.`,
impactedCount: 0,
risk: 'UNKNOWN',
};
}
// ── Bounded direction-aware BFS over CDG + REACHING_DEF (KTD4, KTD11) ──────
// Seed blocks are NOT counted as reachable (they ARE the target); the
// reachable set is everything the BFS discovers from them. Visited tracks
// BOTH seeds and discovered blocks so a cycle never re-expands.
const visited = new Set<string>(seedBlocks);
const reachable = new Set<string>();
let frontier = [...seedBlocks];
let depthReached = 0;
// `truncatedByDepth`: the BFS still had a non-empty frontier when the depth
// budget ran out (more reachable blocks exist past `maxDepth`).
// `truncatedByLimit`: a single step's neighbour query hit the interpolated
// LIMIT, so that step's expansion is a lower bound. Either flags `truncated`.
let truncatedByDepth = false;
let truncatedByLimit = false;
// The endpoint the frontier is matched on, and the endpoint collected, flip
// by direction — but the SAME sense applies to BOTH edge types (KTD4).
// downstream: frontier = source `a`, collect target `b` (forward)
// upstream: frontier = target `b`, collect source `a` (reverse)
const matchEndpoint = direction === 'downstream' ? 'a' : 'b';
const collectEndpoint = direction === 'downstream' ? 'b' : 'a';
for (let depth = 0; depth < depthBudget; depth++) {
if (frontier.length === 0) break;
// Anchored on exact frontier ids (bound as a param — KTD11). The edge-type
// discriminator is a hardcoded literal list (never user input). `LIMIT` is
// the validated integer `stepLimit`.
const rows = await exec(
repo.lbugPath,
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
WHERE r.type IN ['CDG', 'REACHING_DEF'] AND ${matchEndpoint}.id IN $frontier
RETURN DISTINCT ${collectEndpoint}.id AS id
LIMIT ${stepLimit}`,
{ frontier },
);
depthReached = depth + 1;
if (rows.length >= stepLimit) truncatedByLimit = true;
const next: string[] = [];
for (const r of rows) {
const id = String((r as any).id ?? (r as any)[0] ?? '');
if (!id || visited.has(id)) continue;
visited.add(id);
reachable.add(id);
next.push(id);
}
frontier = next;
}
// Frontier still non-empty after exhausting the depth budget ⇒ more blocks
// are reachable beyond `maxDepth` (depth truncation, distinct from natural
// completion where the frontier drains to empty inside the loop).
if (frontier.length > 0) truncatedByDepth = true;
const reachableBlocks = [...reachable].sort();
const truncated = truncatedByDepth || truncatedByLimit;
return {
error: 'pdg mode not yet implemented (U3/U4)',
mode: 'pdg',
target: { name: deps.sym.name, id: deps.sym.id, filePath: deps.sym.filePath },
direction: deps.direction,
impactedCount: 0,
risk: 'UNKNOWN',
target,
direction,
reachableBlocks,
blockCount: reachableBlocks.length,
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,
};
}

View file

@ -137,7 +137,7 @@ withTestLbugDB(
});
describe('ready (both caps stamped)', () => {
it('falls THROUGH the layer check to the traversal (the U2 _runImpactPDG stub)', async () => {
it('falls THROUGH the layer check to the real traversal (U3 _runImpactPDG)', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce(
META({ maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 } as any),
);
@ -147,13 +147,21 @@ withTestLbugDB(
mode: 'pdg',
});
// The layer is complete, so the check did NOT short-circuit: there is no
// degradation note / pdgLayer marker — the call reached the stub instead.
// degradation note / pdgLayer marker — the call reached the traversal.
expect(result.pdgLayer).toBeUndefined();
// U2: the traversal is still the stub. Once U3/U4 land this assertion
// updates to a real blast radius; the load-bearing fact for U2 is that
// `ready` did NOT return a degradation note.
// U3 landed: the stub "not yet implemented" error is gone. `hot` has a
// PDG body (blocks B0→B1) but the only dependent (B1) is itself a seed
// block of the symbol, so the intra-procedural downstream reachable set
// is empty — and that is signalled as a real traversal result with the
// distinct "has a body but no dependence" note, NOT the no-body /
// degradation path, NOT the old stub error. The load-bearing U2 fact —
// `ready` does NOT return a degradation note — still holds.
expect(result.mode).toBe('pdg');
expect(result.error).toMatch(/not yet implemented/i);
expect(result.error).toBeUndefined();
expect(result.note).not.toMatch(/not yet implemented/i);
expect(Array.isArray(result.reachableBlocks)).toBe(true);
// Distinct from KTD6 "no PDG body": this symbol HAS a body.
expect(result.epistemic).not.toBe('no-pdg-body');
});
});

View file

@ -0,0 +1,336 @@
/**
* Integration Tests: `impact` PDG-mode blast-radius TRAVERSAL (U3 / KTD2,4,6,11)
*
* End-to-end against a REAL LadybugDB, through the full `callTool('impact', …)`
* dispatch with `mode:'pdg'`. Exercises `_runImpactPDG` — the direction-aware
* bounded BFS over CDG + REACHING_DEF block edges — the correctness keystone of
* the feature (the KTD4 direction × edge-type truth table).
*
* The intermediate U3 payload exposes the reachable BasicBlock set:
* { mode:'pdg', target, reachableBlocks:[...ids], truncated, depthReached, note? }
* (U4 reshapes this into the consumer-safe impact result; U3 is the traversal.)
*
* ── Fixture graph (hand-seeded, no parser; controlled line numbers) ──────────
* One file `src/flow.ts`. The TARGET symbol `target` is a one-line function at
* 0-based symbol lines [10,10] ⇒ anchor window [11,11], fnLine segment '11'. Its
* single seed block `S` sits at 1-based line 11. All OTHER blocks belong to
* neighbouring functions OUTSIDE that window, so the seed set is exactly {S} and
* every reached block is unambiguously a traversal result, not a co-seed.
*
* RD (def→use, forward = downstream):
* P -[RD]-> S -[RD]-> D1 -[RD]-> D2
* CDG (controller→dependent, forward = downstream):
* C -[CDG]-> S -[CDG]-> K1 -[CDG]-> K2
*
* So from S:
* downstream RD → {D1, D2} (forward; NOT P)
* upstream RD → {P} (reverse; NOT D1/D2)
* downstream CDG → {K1, K2} (forward; NOT C)
* upstream CDG → {C} (reverse; NOT K1/K2)
* downstream (combined) → {D1,D2,K1,K2} (union, same forward sense)
* upstream (combined) → {P, C} (union, same reverse sense)
*
* `loadMeta` is mocked to stamp BOTH caps so `pdgLayerStatus` returns `ready`
* and the call falls through to the real traversal (U2's gate is exercised by
* impact-pdg-degradation.test.ts; here we drive past it).
*/
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, loadMeta } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.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 runs.
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>
const S = `BasicBlock:${F}:11:0:0`; // target `target`'s only block (line 11)
const P = `BasicBlock:${F}:5:0:0`; // predecessor (fn `up`, line 6) — RD def & CDG controller into S
const D1 = `BasicBlock:${F}:20:0:0`; // RD use of S (fn `down`, line 21)
const D2 = `BasicBlock:${F}:20:0:1`; // RD use of D1 (line 22)
const K1 = `BasicBlock:${F}:30:0:0`; // CDG dependent of S (fn `ctl`, line 31)
const K2 = `BasicBlock:${F}:30:0:1`; // CDG dependent of K1 (line 32)
withTestLbugDB(
'impact-pdg-traversal',
(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;
});
const reachable = (result: any): string[] =>
[...((result?.reachableBlocks as string[]) ?? [])].sort();
describe('KTD4 direction × edge-type truth table', () => {
it('downstream REACHING_DEF reaches the use blocks, not the def predecessor', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
const set = reachable(result);
// forward over RD: S→D1→D2 are reached; P (S's def predecessor) is NOT.
expect(set).toContain(D1);
expect(set).toContain(D2);
expect(set).not.toContain(P);
});
it('upstream REACHING_DEF reaches the defs reaching the target, not its uses', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'upstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
const set = reachable(result);
// reverse over RD: P (def reaching S) reached; D1/D2 (uses) NOT.
expect(set).toContain(P);
expect(set).not.toContain(D1);
expect(set).not.toContain(D2);
});
it('downstream CDG reaches the controlled blocks', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
});
const set = reachable(result);
// forward over CDG: S controls K1 controls K2.
expect(set).toContain(K1);
expect(set).toContain(K2);
});
it('upstream CDG reaches the controller, not the controlled blocks', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'upstream',
mode: 'pdg',
});
const set = reachable(result);
// reverse over CDG: C (controller of S) reached; K1/K2 (controlled) NOT.
expect(set).toContain(P);
expect(set).not.toContain(K1);
expect(set).not.toContain(K2);
});
it('combined CDG+RD downstream frontier is the forward union of both', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
});
const set = reachable(result);
expect(set).toEqual([D1, D2, K1, K2].sort());
});
it('combined CDG+RD upstream frontier is the reverse union of both', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'upstream',
mode: 'pdg',
});
const set = reachable(result);
// P is both the RD def AND the CDG controller of S, so the reverse union
// is exactly {P}; the forward successors D1/D2/K1/K2 are never reached.
expect(set).toEqual([P]);
});
});
describe('truncation signalling', () => {
it('maxDepth=1 truncates the chain and flags truncated (not silently short)', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
maxDepth: 1,
});
const set = reachable(result);
// Only the first hop: D1 and K1, NOT D2/K2 (depth 2).
expect(set).toContain(D1);
expect(set).toContain(K1);
expect(set).not.toContain(D2);
expect(set).not.toContain(K2);
expect(result.truncated).toBe(true);
expect(result.depthReached).toBe(1);
});
it('full traversal completing within depth is NOT flagged truncated', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
maxDepth: 10,
});
expect(result.truncated).toBeFalsy();
});
it('limit truncation bounds the reachable set and flags truncated', async () => {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
maxDepth: 10,
limit: 1,
});
// A limit of 1 cannot expand the full union — the result is bounded and
// flagged so a caller never reads the clipped set as the whole radius.
expect(reachable(result).length).toBeLessThan(4);
expect(result.truncated).toBe(true);
});
it('rejects or clamps a negative / huge / NaN limit (validated int interpolation)', async () => {
for (const limit of [-1, NaN, 1.5]) {
const result = await backend.callTool('impact', {
target: 'target',
direction: 'downstream',
mode: 'pdg',
limit,
});
// Either a clean validation error OR a clamp to a sane default — but
// NEVER an unbounded/garbage interpolation or a crash.
if (result.error) {
expect(result.error).toMatch(/limit/i);
} else {
// Clamped: the traversal still produced its normal union.
expect(reachable(result).length).toBeGreaterThan(0);
}
}
});
});
describe('KTD6 no-body symbol contract', () => {
it('a symbol with no BasicBlocks returns an explicit note, never a confident zero', async () => {
const result = await backend.callTool('impact', {
target: 'IShape', // interface — resolves, but has no CFG body / blocks
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
// Explicit "no PDG body for this symbol kind" signal …
expect(result.note).toMatch(/no.*(body|block|dependence)/i);
// … and NEVER a silent confident-LOW / impactedCount:0 with no marker.
expect(result.risk).not.toBe('LOW');
expect(reachable(result)).toEqual([]);
});
});
describe('KTD11 injection safety', () => {
it("a target containing a quote/colon is bound, not interpolated (no crash, no injection)", async () => {
// A malicious-looking target must flow through a bind param. It simply
// resolves to not-found here (no such symbol) — never a Cypher error.
const result = await backend.callTool('impact', {
target: `evil':' OR 1=1 //`,
direction: 'downstream',
mode: 'pdg',
});
// Not a syntax/crash error — a clean not-found (param-bound).
expect(result.error).toMatch(/not found/i);
});
});
describe('KTD2 ambiguous-anchor names the impact tool', () => {
it("an ambiguous target under mode:'pdg' names `impact`, not `pdg_query`", async () => {
const result = await backend.callTool('impact', {
target: 'dupTarget', // two functions share this name
direction: 'downstream',
mode: 'pdg',
});
expect(result.status).toBe('ambiguous');
// The ambiguous path is the _impactImpl one (U1), which already names the
// tool correctly; the load-bearing U3 fact is that resolveBlockAnchor's
// widened union never emits a `pdg_query` message for an impact call.
const blob = JSON.stringify(result);
expect(blob).not.toMatch(/pdg_query/);
});
});
},
{
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: 'traversal 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). `target` is the one-line seed fn.
await fn('func:target', 'target', 10, 10); // window [11,11] ⇒ seed {S}
await fn('func:up', 'up', 4, 4); // P at line 6 — outside [11,11]
await fn('func:down', 'down', 19, 21); // D1/D2 — outside [11,11]
await fn('func:ctl', 'ctl', 29, 31); // K1/K2 — outside [11,11]
// No-body symbol: an interface with NO BasicBlocks at all.
await fn('func:IShape', 'IShape', 40, 42, 'Interface');
// Ambiguous: two functions sharing a name.
await fn('func:dupTarget@a', 'dupTarget', 50, 52);
await fn('func:dupTarget@b', 'dupTarget', 60, 62);
// Blocks.
await block(S, 11, 'const x = compute();'); // target's seed block
await block(P, 6, 'const seed = input();'); // predecessor
await block(D1, 21, 'use(x);'); // RD use
await block(D2, 22, 'log(x);'); // RD use of D1
await block(K1, 31, 'doA();'); // CDG dependent
await block(K2, 32, 'doB();'); // CDG dependent of K1
// 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 (controller→dependent): C(=P) → S → K1 → K2
await edge('CDG', P, S, 'T');
await edge('CDG', S, K1, 'T');
await edge('CDG', K1, K2, 'T');
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'traversal-repo',
path: '/traversal/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'trav123',
stats: { files: 1, nodes: 12, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);

View file

@ -1457,7 +1457,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect(undef).toEqual(absent);
});
it("mode:'pdg' routes to the PDG stub and NEVER runs the callgraph BFS (KTD5)", async () => {
it("mode:'pdg' routes to the PDG traversal and NEVER runs the callgraph BFS (KTD5)", async () => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const result = await backend.callTool('impact', {
@ -1465,10 +1465,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
direction: 'upstream',
mode: 'pdg',
});
// Stub payload — pending until U3/U4.
expect(result.error).toMatch(/not yet implemented/);
// U3 landed — the call reaches the real `_runImpactPDG` traversal, not the
// old "not yet implemented" stub. It returns a pdg-shaped payload.
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
// The callgraph engine must never be invoked under a pdg call.
expect(Array.isArray(result.reachableBlocks)).toBe(true);
// The load-bearing KTD5 invariant: the callgraph engine must NEVER be
// invoked under a pdg call (no silent fallback).
expect(bfsSpy).not.toHaveBeenCalled();
});