From f85a066d050d015615210d6f1f83dc2f54db9fcf Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 16 Jun 2026 07:46:43 +0000 Subject: [PATCH] feat(impact): PDG-layer presence + degradation contract (U2) Extract the 3-state meta-probe from _pdgQueryImpl into pdgLayerStatus, a both-caps helper returning no-layer / sub-layer-missing / ready / unknown without scanning the DB (except one bounded LIMIT 1 probe when meta is unreadable). _pdgQueryImpl now consumes a single-cap projection (pdgStampForMode) byte-identically. Wired into _impactImpl's pdg branch so degraded indexes return a distinct note before the stub. Refs U2 --- gitnexus/src/mcp/local/local-backend.ts | 205 ++++++++++++++-- .../impact-pdg-degradation.test.ts | 219 ++++++++++++++++++ gitnexus/test/unit/calltool-dispatch.test.ts | 17 +- 3 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 gitnexus/test/integration/impact-pdg-degradation.test.ts diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index dd6d315e1..5b1426c83 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -122,6 +122,159 @@ function validateImpactMode(rawMode: unknown): { mode: ImpactMode } | { error: s error: `Invalid "mode": expected "callgraph" or "pdg", got ${JSON.stringify(rawMode)}.`, }; } + +/** The two independently-stamped PDG sub-layers (KTD7). */ +export type PdgSubLayer = 'CDG' | 'REACHING_DEF'; + +/** + * Four-state PDG-layer presence/degradation status (KTD7). + * + * - `'no-layer'` — `meta.pdg` is absent: this repo was never analyzed + * with `--pdg` (definitive; established with NO DB scan). + * - `'sub-layer-missing'`— exactly one of the two independently-stamped caps + * (`maxCdgEdgesPerFunction` / `maxReachingDefEdgesPerFunction`) + * is present. `impact`'s PDG mode needs BOTH, so a + * partial layer must not be reported as complete; the + * missing one is named in `missingSubLayer`. + * - `'ready'` — both caps present: the layer is fully stamped. + * - `'unknown'` — meta is unreadable (e.g. a seeded test DB with no + * `meta.json`). One bounded `LIMIT 1` probe distinguishes + * a genuinely edge-free index from a missing one; either + * way the conclusion is inconclusive (a missing layer is + * indistinguishable from an all-linear one — #2188). + */ +export interface PdgLayerStatus { + state: 'no-layer' | 'sub-layer-missing' | 'ready' | 'unknown'; + /** Set only for `'sub-layer-missing'` — the cap that was NOT stamped. */ + missingSubLayer?: PdgSubLayer; + /** Human-readable guidance for the degraded states (absent for `'ready'`). */ + note?: string; +} + +/** + * Per-cap presence read from `meta.pdg`, plus whether meta was readable at all. + * + * `metaReadable` is the seam between the `'unknown'` state (meta unreadable — + * fall through to a DB probe) and the meta-stamped states. When `metaReadable` + * is true but `meta.pdg` was absent, both `cdg`/`rd` are `false`. + */ +interface PdgMetaCaps { + metaReadable: boolean; + /** `maxCdgEdgesPerFunction !== undefined` (only meaningful when metaReadable). */ + cdg: boolean; + /** `maxReachingDefEdgesPerFunction !== undefined` (only meaningful when metaReadable). */ + rd: boolean; +} + +/** + * Read the two PDG sub-layer caps from the on-disk `meta.json` stamp — the + * single shared meta-probe both `_pdgQueryImpl` (one cap) and the PDG impact + * mode (both caps) key on. Never scans the DB. An unreadable / missing meta + * yields `metaReadable: false` (the `'unknown'` seam); a readable meta with no + * `pdg` stamp yields `metaReadable: true` with both caps `false` (no-layer). + */ +async function readPdgMetaCaps( + lbugPath: string, + loadMetaFn: typeof loadMeta, +): Promise { + try { + const meta = await loadMetaFn(path.dirname(lbugPath)); + if (!meta) return { metaReadable: false, cdg: false, rd: false }; + return { + metaReadable: true, + cdg: meta.pdg?.maxCdgEdgesPerFunction !== undefined, + rd: meta.pdg?.maxReachingDefEdgesPerFunction !== undefined, + }; + } catch { + // Meta unreadable — the caller decides from the DB (the `'unknown'` state). + return { metaReadable: false, cdg: false, rd: false }; + } +} + +/** + * Project the both-caps PDG meta read down to the single mode-relevant cap that + * `_pdgQueryImpl` keys on (`controls` → CDG, `flows` → REACHING_DEF), preserving + * its established tri-state `boolean | undefined` contract byte-for-byte + * (Feasibility Issue 4): + * - `false` — meta readable and the relevant cap absent → definitive + * no-layer (short-circuits before any DB scan). + * - `true` — meta readable and the relevant cap present → proceed. + * - `undefined` — meta unreadable → defer to the post-anchored-query probe. + * + * `_pdgQueryImpl` needs only ONE cap, so it collapses the both-caps read here + * rather than consuming `pdgLayerStatus` directly (whose `'unknown'` state does + * an upfront global probe — wrong timing/order for the anchored-query path). + */ +async function pdgStampForMode( + lbugPath: string, + mode: 'controls' | 'flows', + loadMetaFn: typeof loadMeta = loadMeta, +): Promise { + const caps = await readPdgMetaCaps(lbugPath, loadMetaFn); + if (!caps.metaReadable) return undefined; + return mode === 'controls' ? caps.cdg : caps.rd; +} + +/** + * PDG-layer presence/degradation check for the `impact` PDG mode (KTD7). + * + * Returns the four distinct states WITHOUT scanning the DB except for the single + * bounded `LIMIT 1` probe the `'unknown'` (meta-unreadable) case requires. The + * caller (`_impactImpl` PDG branch, and the accuracy harness) surfaces a + * distinct signal per state so a missing `--pdg` layer / partial layer is never + * silently misread as a confident empty blast radius. Impact needs BOTH the CDG + * and the REACHING_DEF sub-layer, so a partial stamp degrades, not proceeds. + * + * Deps are injected (KTD2 extraction discipline) so this can move to a future + * `pdg-impact.ts` engine as a move, not a rewrite. + */ +async function pdgLayerStatus(deps: { + lbugPath: string; + executeParameterized: typeof executeParameterized; + loadMetaFn?: typeof loadMeta; +}): Promise { + const loadMetaFn = deps.loadMetaFn ?? loadMeta; + const caps = await readPdgMetaCaps(deps.lbugPath, loadMetaFn); + + if (caps.metaReadable) { + // Meta is readable — the stamp is authoritative, no DB scan needed. + if (caps.cdg && caps.rd) return { state: 'ready' }; + if (caps.cdg !== caps.rd) { + // Exactly one sub-layer stamped (XOR) — partial layer; impact needs both. + const missingSubLayer: PdgSubLayer = caps.cdg ? 'REACHING_DEF' : 'CDG'; + return { + state: 'sub-layer-missing', + missingSubLayer, + note: + `PDG layer is incomplete — the ${missingSubLayer} sub-layer is missing ` + + `(impact's PDG mode needs both CDG and REACHING_DEF). ` + + `Re-run gitnexus analyze --pdg to record it.`, + }; + } + // Neither cap stamped (meta.pdg absent, or present with no caps) → the layer + // was never recorded. Definitive, no DB scan. + return { + state: 'no-layer', + note: 'no PDG layer — run gitnexus analyze --pdg to record CDG + REACHING_DEF edges for this repo', + }; + } + + // Meta unreadable (e.g. a seeded test DB): one bounded probe confirms the + // layer status is genuinely undeterminable from the DB. A missing layer is + // indistinguishable from an all-linear (edge-free) one (#2188), so whether the + // probe finds a row or not the note stays inconclusive ("status unknown") — + // never the definitive no-layer wording. The probe is bounded (LIMIT 1) and + // anchored on the edge-type discriminator, never an unbounded path scan. + await deps.executeParameterized( + deps.lbugPath, + `MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock) WHERE r.type IN ['CDG', 'REACHING_DEF'] RETURN r.type AS type LIMIT 1`, + {}, + ); + return { + state: 'unknown', + note: 'PDG layer status unknown — no CDG/REACHING_DEF edges visible and meta is unreadable; was this repo indexed with gitnexus analyze --pdg?', + }; +} // AI context generation is CLI-only (gitnexus analyze) // import { generateAIContextFiles } from '../../cli/ai-context.js'; @@ -3322,18 +3475,13 @@ export class LocalBackend { // Cheap meta probe: the layer exists iff the pdg stamp carries the // mode-relevant cap (maxCdgEdgesPerFunction for CDG, maxReachingDef… // for REACHING_DEF). Absent ⇒ the no-layer hint without a DB scan. - let pdgStamped: boolean | undefined; - try { - const meta = await loadMeta(path.dirname(repo.lbugPath)); - if (meta) { - pdgStamped = - mode === 'controls' - ? meta.pdg?.maxCdgEdgesPerFunction !== undefined - : meta.pdg?.maxReachingDefEdgesPerFunction !== undefined; - } - } catch { - /* meta unreadable — decide from the DB below */ - } + // `pdgStampForMode` is the shared meta read (the both-caps `pdgLayerStatus` + // helper consumes the same underlying read for impact); here we project it + // down to this one mode's cap, preserving the tri-state `boolean | undefined` + // contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit + // below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the + // post-anchored-query probe (Feasibility Issue 4). + const pdgStamped = await pdgStampForMode(repo.lbugPath, mode); if (pdgStamped === false) { return { mode, results: [], total: 0, note: NO_PDG_NOTE }; } @@ -4317,10 +4465,35 @@ export class LocalBackend { } } - // (2) PDG-layer presence probe — STUB for U1; U2 fills in the four-state - // degradation contract (no-layer / partial / unknown / ready) here, before - // any DB scan, so a missing `--pdg` layer returns a guidance note rather - // than a confusing empty traversal. Intentionally a no-op for now. + // (2) PDG-layer presence probe (U2, KTD7) — the four-state degradation + // contract, BEFORE resolveSymbolCandidates / any traversal. A repo never + // analyzed with `--pdg` (no-layer), one with only a partial layer + // (sub-layer-missing — impact needs BOTH CDG and REACHING_DEF), or one whose + // meta is unreadable (unknown) each returns a distinct guidance note here + // rather than a confusing empty blast radius. Only `ready` falls through to + // the traversal (the `_runImpactPDG` stub until U3/U4). This fires before the + // stub deliberately, so a degraded layer is reported as such, not as + // "pdg mode not yet implemented". + if (mode === 'pdg') { + const layer = await pdgLayerStatus({ + lbugPath: repo.lbugPath, + executeParameterized, + }); + if (layer.state !== 'ready') { + return { + mode, + pdgLayer: layer.state, + ...(layer.missingSubLayer ? { missingSubLayer: layer.missingSubLayer } : {}), + note: layer.note, + target: { name: target }, + direction, + // No confident zero: a degraded layer is inconclusive, not "safe to + // refactor". UNKNOWN (KTD8) — never LOW (#2129/#1858 false-safe). + impactedCount: 0, + risk: 'UNKNOWN', + }; + } + } const maxDepth = params.maxDepth || 3; // Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES) diff --git a/gitnexus/test/integration/impact-pdg-degradation.test.ts b/gitnexus/test/integration/impact-pdg-degradation.test.ts new file mode 100644 index 000000000..c60775db1 --- /dev/null +++ b/gitnexus/test/integration/impact-pdg-degradation.test.ts @@ -0,0 +1,219 @@ +/** + * Integration Tests: `impact` PDG-mode layer degradation contract (U2 / KTD7) + * + * End-to-end against a REAL LadybugDB, through the full `callTool('impact', …)` + * dispatch. Exercises the four-state PDG-layer presence/degradation check + * (`pdgLayerStatus`) wired into `_impactImpl`'s PDG branch — the check that + * fires BEFORE symbol resolution / traversal so a missing or partial `--pdg` + * layer returns a distinct guidance note instead of a confusing empty blast + * radius (or the U2-era `_runImpactPDG` "not yet implemented" stub error). + * + * The four states (KTD7) are driven by what the (mocked) `loadMeta` returns — + * matching the seeded-DB reality that there is no on-disk `meta.json`: + * - no-layer : meta readable, no `pdg` stamp → run analyze --pdg + * - sub-layer-missing : exactly one cap stamped (CDG xor RD) → names the missing one + * - ready : both caps stamped → falls through to the stub + * - unknown : meta unreadable (null) → inconclusive, via 1 LIMIT 1 probe + * + * The `_runImpactPDG` traversal is still a stub in U2, so the `ready` case + * asserts the layer check let it THROUGH (the stub's "not yet implemented" + * sentinel), proving the check is ordered before the stub for the degraded + * states and falls through only when the layer is complete. + */ +import { describe, it, expect, beforeAll, beforeEach, 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'; +import * as poolAdapter from '../../src/core/lbug/pool-adapter.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([]), + // Default: meta unreadable (the seeded-DB reality — no on-disk meta.json). + // Individual tests override per state via mockResolvedValueOnce. + loadMeta: vi.fn().mockResolvedValue(null), + }; +}); + +// Minimal seed: one Function symbol (so a `ready` index could resolve it) plus +// a single BasicBlock + CDG edge so the `unknown` state's LIMIT 1 probe finds a +// row (it must STILL stay inconclusive — a present edge cannot disprove an +// edge-free layer / #2188). +const SEED = [ + `CREATE (fn:Function {id: 'func:hot', name: 'hot', filePath: 'src/hot.ts', startLine: 1, endLine: 5, isExported: true, content: 'function hot() {}', description: 'degradation fixture'})`, + `CREATE (b0:BasicBlock {id: 'BasicBlock:src/hot.ts:1:0:0', filePath: 'src/hot.ts', startLine: 2, endLine: 2, text: 'if (x)'})`, + `CREATE (b1:BasicBlock {id: 'BasicBlock:src/hot.ts:1:0:1', filePath: 'src/hot.ts', startLine: 3, endLine: 3, text: 'doThing();'})`, +]; +const SEED_EDGE = `MATCH (a:BasicBlock {id: 'BasicBlock:src/hot.ts:1:0:0'}), (b:BasicBlock {id: 'BasicBlock:src/hot.ts:1:0:1'}) + CREATE (a)-[:CodeRelation {type: 'CDG', confidence: 1.0, reason: 'T', step: 0}]->(b)`; + +const META = (pdg?: RepoMeta['pdg']): RepoMeta => ({ pdg } as unknown as RepoMeta); + +withTestLbugDB( + 'impact-pdg-degradation', + (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; + }); + + // Reset the loadMeta mock to the default (unreadable) before each test so a + // mockResolvedValueOnce set in one test never leaks into the next. + beforeEach(() => { + vi.mocked(loadMeta).mockReset(); + vi.mocked(loadMeta).mockResolvedValue(null); + }); + + describe('no-layer (meta readable, no pdg stamp)', () => { + it('returns the definitive "run analyze --pdg" note — and does NOT scan the DB', async () => { + // Readable meta with no `pdg` key ⇒ the layer was never recorded. + vi.mocked(loadMeta).mockResolvedValueOnce(META(undefined)); + const spy = vi.spyOn(poolAdapter, 'executeParameterized'); + spy.mockClear(); + + const result = await backend.callTool('impact', { + target: 'hot', + direction: 'downstream', + mode: 'pdg', + }); + + // Definitive, meta-derived: no DB probe ran, so executeParameterized was + // never called between the spy clear and here (the no-layer branch + // returns before the probe AND before resolveSymbolCandidates). + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + + expect(result.mode).toBe('pdg'); + expect(result.pdgLayer).toBe('no-layer'); + expect(result.note).toMatch(/no PDG layer/i); + expect(result.note).toContain('--pdg'); + // Not the stub, not a status-unknown note, not a confident LOW. + expect(result.error).toBeUndefined(); + expect(result.note).not.toMatch(/status unknown/i); + expect(result.note).not.toMatch(/not yet implemented/i); + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBe(0); + }); + }); + + describe('sub-layer-missing (exactly one cap stamped)', () => { + it('CDG present, RD absent → names REACHING_DEF as missing', async () => { + vi.mocked(loadMeta).mockResolvedValueOnce(META({ maxCdgEdgesPerFunction: 0 } as any)); + const result = await backend.callTool('impact', { + target: 'hot', + direction: 'downstream', + mode: 'pdg', + }); + expect(result.pdgLayer).toBe('sub-layer-missing'); + expect(result.missingSubLayer).toBe('REACHING_DEF'); + expect(result.note).toMatch(/REACHING_DEF/); + // Partial layer must NOT be reported as complete (not the stub, no LOW). + expect(result.note).not.toMatch(/not yet implemented/i); + expect(result.risk).toBe('UNKNOWN'); + }); + + it('RD present, CDG absent → names CDG as missing', async () => { + vi.mocked(loadMeta).mockResolvedValueOnce( + META({ maxReachingDefEdgesPerFunction: 0 } as any), + ); + const result = await backend.callTool('impact', { + target: 'hot', + direction: 'downstream', + mode: 'pdg', + }); + expect(result.pdgLayer).toBe('sub-layer-missing'); + expect(result.missingSubLayer).toBe('CDG'); + expect(result.note).toMatch(/\bCDG\b/); + expect(result.note).not.toMatch(/not yet implemented/i); + expect(result.risk).toBe('UNKNOWN'); + }); + }); + + describe('ready (both caps stamped)', () => { + it('falls THROUGH the layer check to the traversal (the U2 _runImpactPDG stub)', async () => { + vi.mocked(loadMeta).mockResolvedValueOnce( + META({ maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 } as any), + ); + const result = await backend.callTool('impact', { + target: 'hot', + direction: 'downstream', + 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. + 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. + expect(result.mode).toBe('pdg'); + expect(result.error).toMatch(/not yet implemented/i); + }); + }); + + describe('unknown (meta unreadable)', () => { + it('returns the inconclusive "status unknown" note via a bounded probe, even with edges present', async () => { + // loadMeta defaults to null (unreadable) via beforeEach. The seeded DB + // DOES carry a CDG edge, but the note must stay inconclusive — a present + // edge cannot prove the layer is complete, and a missing one is + // indistinguishable from an edge-free index (#2188). + const result = await backend.callTool('impact', { + target: 'hot', + direction: 'downstream', + mode: 'pdg', + }); + expect(result.pdgLayer).toBe('unknown'); + expect(result.note).toMatch(/status unknown/i); + expect(result.note).toContain('--pdg'); + // Inconclusive ≠ definitive no-layer wording. + expect(result.note).not.toMatch(/no PDG layer/i); + expect(result.note).not.toMatch(/not yet implemented/i); + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBe(0); + }); + }); + + describe('callgraph mode is unaffected by the PDG-layer probe', () => { + it('mode:callgraph never consults the PDG layer (no degradation note)', async () => { + // Even with meta unreadable, a callgraph impact resolves the symbol and + // returns a real (here: empty-graph) blast radius, never a PDG note. + const result = await backend.callTool('impact', { + target: 'hot', + direction: 'downstream', + mode: 'callgraph', + }); + expect(result.pdgLayer).toBeUndefined(); + // The callgraph path never sets a PDG degradation note. (It may carry + // its own callgraph-flavored notes, but never the PDG-layer wording.) + const note = typeof result.note === 'string' ? result.note : ''; + expect(note).not.toMatch(/status unknown/i); + expect(note).not.toMatch(/no PDG layer/i); + }); + }); + }, + { + seed: [...SEED, SEED_EDGE], + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'degradation-repo', + path: '/degradation/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'deg123', + stats: { files: 1, nodes: 3, 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 5e590cf93..101773e4d 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -47,6 +47,14 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { listRegisteredRepos: vi.fn().mockResolvedValue([]), cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), findSiblingClones: vi.fn().mockResolvedValue([]), + // U2: expose loadMeta as a spy that delegates to the REAL implementation by + // default (so branch-scope resolution, #2106, is unaffected). The + // impact-mode block overrides it per-test to stamp a READY PDG layer, so the + // U2 layer-presence probe falls THROUGH to the post-check surface (the + // `_runImpactPDG` stub / ambiguous fan-out) those tests assert. The + // four-state degradation contract itself is covered in + // test/integration/impact-pdg-degradation.test.ts. + loadMeta: vi.fn(actual.loadMeta), }; }); @@ -97,7 +105,7 @@ import { REPO_ID_HASH_LENGTH, parseListReposPagination, } from '../../src/mcp/local/local-backend.js'; -import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js'; +import { listRegisteredRepos, cleanupOldKuzuFiles, loadMeta } from '../../src/storage/repo-manager.js'; import { getGitRoot } from '../../src/storage/git.js'; import { _captureLogger } from '../../src/core/logger.js'; import { @@ -1409,6 +1417,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { beforeEach(async () => { vi.clearAllMocks(); platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true); + // U2: stamp a READY PDG layer (both caps) so the layer-presence probe in + // `_impactImpl` falls THROUGH to the mode-dispatch surface these tests pin + // (the `_runImpactPDG` stub / the ambiguous fan-out under `mode:'pdg'`). + // Degraded-layer behavior is owned by the integration degradation suite. + vi.mocked(loadMeta).mockResolvedValue({ + pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 }, + } as any); backend = new LocalBackend(); setupSingleRepo(); await backend.init();