GitNexus/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts
Gergő Magyar 78b4077d8a
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227)
2026-06-20 12:04:32 +01:00

298 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit Tests: PDG impact dispatch — resolved-callee-id bridge (U6)
*
* Pins the U6 wiring that makes the U5 sound id-match actually fire end-to-end:
* 1. the dispatch loads the slice blocks' `BasicBlock.calleeIds`
* (`calleeIdsOfBlocks`) from the SAME seed ∪ reachable set as the names, and
* 2. the BFS evidence-stamping loop feeds the reached callee's resolved id
* (`relId`) into `pdgBridgeEvidenceForImpact`, so a first-hop callee is proven
* by `id ∈ sliceCalleeIds` (KTD3) rather than by leaf name.
*
* These drive the REAL `_runImpactBFS` (only `_runImpactPDG` is stubbed to supply
* the slice blocks) so the `calleeId` wiring in the stamping loop is exercised, and
* assert the verdict on the composed `interproceduralByDepth[depth][i].pdgEvidence`.
*
* Mocking mirrors test/unit/calltool-dispatch.test.ts exactly (same hoisted lbug
* mocks, same READY-PDG-layer `loadMeta` stamp, `executeParameterized` typed to its
* real signature via vi.mocked).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { lbugMocks, platformMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
executeParameterized: vi.fn().mockResolvedValue([]),
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, ...lbugMocks };
});
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, ...lbugMocks };
});
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([]),
loadMeta: vi.fn(actual.loadMeta),
};
});
vi.mock('../../src/core/git-staleness.js', () => ({
checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }),
checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }),
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));
vi.mock('../../src/storage/git.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/git.js')>();
return {
...actual,
getGitRoot: vi.fn().mockReturnValue(null),
};
});
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return {
...actual,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
}));
vi.mock('../../src/mcp/core/embedder.js', () => ({
embedQuery: vi.fn().mockResolvedValue([]),
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
import { executeParameterized } from '../../src/mcp/core/lbug-adapter.js';
// ─── Helpers ─────────────────────────────────────────────────────────
const MOCK_REPO_ENTRY = {
name: 'test-project',
path: '/tmp/test-project',
storagePath: '/tmp/.gitnexus/test-project',
indexedAt: '2024-06-01T12:00:00Z',
lastCommit: 'abc1234567890',
stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 },
};
const TARGET_ROW = {
id: 'func:main',
name: 'main',
type: 'Function',
filePath: 'src/index.ts',
};
/**
* A `_runImpactPDG` stub result whose slice carries one reachable + one seed
* block — the dispatch queries both for `callees`/`calleeIds` (seed ∪ reachable).
*/
const PDG_SLICE_RESULT = {
mode: 'pdg',
target: TARGET_ROW,
direction: 'downstream',
risk: 'UNKNOWN',
impactedCount: 0,
epistemic: 'pdg-intra-procedural',
reachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
// Intra-only slice ⇒ the intra reach the bridge keys on equals reachableBlocks
// (FIX 6: bridge keys its first-hop-proven set on intraReachableBlocks).
intraReachableBlocks: ['BasicBlock:src/index.ts:8:0:1'],
seedBlocks: ['BasicBlock:src/index.ts:8:0:0'],
blockCount: 1,
affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }],
affectedStatementCount: 1,
criterionLine: 8,
};
/** Build a downstream BFS frontier row (one reached callee). */
function frontierRow(id: string, name: string) {
return {
sourceId: 'func:main',
id,
name,
type: 'Function',
filePath: 'src/callee.ts',
relType: 'CALLS',
confidence: 0.9,
};
}
describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
// READY PDG layer so the dispatch reaches the mode-dispatch / bridge surface.
vi.mocked(loadMeta).mockResolvedValue({
pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 },
} as unknown as Awaited<ReturnType<typeof loadMeta>>);
vi.mocked(listRegisteredRepos).mockResolvedValue([MOCK_REPO_ENTRY] as unknown as Awaited<
ReturnType<typeof listRegisteredRepos>
>);
backend = new LocalBackend();
await backend.init();
});
it('proves a seed-line callee by resolved id (calleeIds slice match overrides name path)', async () => {
vi.spyOn(
backend as unknown as { _runImpactPDG: () => Promise<unknown> },
'_runImpactPDG',
).mockResolvedValueOnce({ ...PDG_SLICE_RESULT });
// calleeIds carries the reached callee's id; callees carries a leaf name that
// does NOT match the reached callee's name → only the id path can prove it.
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
if (query.includes('RETURN b.calleeIds')) return [{ calleeIds: 'func:callee-A' }];
if (query.includes('RETURN b.callees')) return [{ callees: 'someOtherLeaf' }];
if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) {
return [frontierRow('func:callee-A', 'callee')];
}
if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return [];
// Target resolution (WHERE n.name = $symName) and any other read.
return [TARGET_ROW];
});
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
// The reached callee is proven by id ∈ sliceCalleeIds even though its leaf
// name is absent from sliceCalleeNames.
expect(result.interproceduralByDepth[1][0]).toMatchObject({
id: 'func:callee-A',
pdgEvidence: 'callgraph-bridge',
});
});
it('falls back to the name path when the calleeIds query errors (empty id set, no crash)', async () => {
vi.spyOn(
backend as unknown as { _runImpactPDG: () => Promise<unknown> },
'_runImpactPDG',
).mockResolvedValueOnce({ ...PDG_SLICE_RESULT });
// The calleeIds query throws → calleeIdsOfBlocks swallows → empty id set → the
// bridge uses the name path (callees carries the reached callee's leaf name).
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
if (query.includes('RETURN b.calleeIds')) throw new Error('calleeIds query failed');
if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }];
if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) {
return [frontierRow('func:callee-A', 'callee')];
}
if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return [];
return [TARGET_ROW];
});
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
// No crash, no surfaced error; the name path proves the reached callee.
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.interproceduralByDepth[1][0]).toMatchObject({
id: 'func:callee-A',
pdgEvidence: 'callgraph-bridge',
});
});
it('discriminates same-leaf-name callees by id end-to-end (only the in-slice id is proven)', async () => {
vi.spyOn(
backend as unknown as { _runImpactPDG: () => Promise<unknown> },
'_runImpactPDG',
).mockResolvedValueOnce({ ...PDG_SLICE_RESULT });
// Two reached callees share the leaf name 'callee'. sliceCalleeIds carries ONLY
// func:callee-A → the id path proves A and leaves B unproven, where the name
// path (which sees 'callee' for both) would have over-attributed both.
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
if (query.includes('RETURN b.calleeIds')) return [{ calleeIds: 'func:callee-A' }];
if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }];
if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) {
return [frontierRow('func:callee-A', 'callee'), frontierRow('func:callee-B', 'callee')];
}
if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return [];
return [TARGET_ROW];
});
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
expect(result.error).toBeUndefined();
const items = result.interproceduralByDepth[1] as Array<{ id: string; pdgEvidence: string }>;
const byId = new Map(items.map((i) => [i.id, i.pdgEvidence]));
// Only the in-slice id is proven; the same-named sibling is a proof failure.
expect(byId.get('func:callee-A')).toBe('callgraph-bridge');
expect(byId.get('func:callee-B')).toBe('unproven-bridge');
});
it('drops the truncation sentinel from the slice id set (R7 — never matched as an id)', async () => {
vi.spyOn(
backend as unknown as { _runImpactPDG: () => Promise<unknown> },
'_runImpactPDG',
).mockResolvedValueOnce({ ...PDG_SLICE_RESULT });
// calleeIds carries the sentinel '*' alongside a real id; callees has NO sentinel
// (so the names-sentinel short-circuit does not fire and the id path runs). A
// reached callee whose id is literally '*' must be UNPROVEN — the sentinel was
// filtered out of the id set, so it can never false-match. (TAB-joined per the
// CALLEE_ID_SEP delimiter — ids can contain spaces, so the cell is not space-joined.)
vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => {
if (query.includes('RETURN b.calleeIds')) return [{ calleeIds: 'func:callee-A\t*' }];
if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }];
if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) {
return [frontierRow('func:callee-A', 'callee'), frontierRow('*', 'callee')];
}
if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return [];
return [TARGET_ROW];
});
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
expect(result.error).toBeUndefined();
const items = result.interproceduralByDepth[1] as Array<{ id: string; pdgEvidence: string }>;
const byId = new Map(items.map((i) => [i.id, i.pdgEvidence]));
expect(byId.get('func:callee-A')).toBe('callgraph-bridge');
expect(byId.get('*')).toBe('unproven-bridge');
});
});