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
This commit is contained in:
Gergo Magyar 2026-06-16 07:46:43 +00:00
parent 00dc899d34
commit f85a066d05
3 changed files with 424 additions and 17 deletions

View file

@ -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<PdgMetaCaps> {
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<boolean | undefined> {
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<PdgLayerStatus> {
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)

View file

@ -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<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([]),
// 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;
},
},
);

View file

@ -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();