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