diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5b1426c83..564071d5d 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -97,6 +97,21 @@ function resolveAliasString(canonical: unknown, legacy: unknown): string | undef return undefined; } +/** + * Parse the `` segment out of a `BasicBlock` id (1-based function start + * line). The id template is + * `BasicBlock::::` + * and `` may itself contain `':'` (a Windows drive letter), so the + * segments are taken from the RIGHT: `` is last, `` second-last, + * `` 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; @@ -3541,12 +3556,7 @@ export class LocalBackend { } // basicBlockId = `BasicBlock::::` — 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 (`.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 { + 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(seedBlocks); + const reachable = new Set(); + 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, }; } diff --git a/gitnexus/test/integration/impact-pdg-degradation.test.ts b/gitnexus/test/integration/impact-pdg-degradation.test.ts index c60775db1..258295d41 100644 --- a/gitnexus/test/integration/impact-pdg-degradation.test.ts +++ b/gitnexus/test/integration/impact-pdg-degradation.test.ts @@ -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'); }); }); diff --git a/gitnexus/test/integration/impact-pdg-traversal.test.ts b/gitnexus/test/integration/impact-pdg-traversal.test.ts new file mode 100644 index 000000000..1db6ef8f6 --- /dev/null +++ b/gitnexus/test/integration/impact-pdg-traversal.test.ts @@ -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(); + 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:::: +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; + }, + }, +); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 101773e4d..52cad4ed5 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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(); });