GitNexus/gitnexus/bench/impact-pdg/real-code.mjs
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

457 lines
16 KiB
JavaScript

/**
* Real-code performance and quality proxy probe for impact modes.
*
* This complements `measure.mjs`, which is the ground-truth accuracy gate over
* curated fixtures. A real repository does not have an AIS annotation set, so
* this probe does NOT claim accuracy. It checks whether unified `mode:'pdg'`
* preserves the established callgraph symbol reach on a real index, how much it
* costs, and how honest its PDG evidence/degraded signals are.
*/
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
export const DEFAULT_REAL_CODE_CASES = [
{
name: 'cli-format-impact-upstream',
target: 'formatImpactResult',
file_path: 'gitnexus/src/cli/eval-server.ts',
kind: 'Function',
direction: 'upstream',
line: 208,
},
{
name: 'cli-impact-command-downstream',
target: 'impactCommand',
file_path: 'gitnexus/src/cli/tool.ts',
kind: 'Function',
direction: 'downstream',
// Block-start line of the coalesced backend-call statement group. The CFG
// coalesces lines 162-192 into one BasicBlock, so an anchor mid-block (e.g.
// 173) lands on no block start and degrades to pdg-no-block-at-line. Seed the
// block's start line so the intra slice is exercised on a real statement.
line: 162,
},
{
name: 'pdg-engine-downstream',
target: 'runImpactPDG',
file_path: 'gitnexus/src/mcp/local/pdg-impact.ts',
kind: 'Function',
direction: 'downstream',
// Block-start line of the function's opening coalesced statement group
// (the destructure + budget setup spanning 912+). Mid-block lines like 952
// resolve to no block start; 912 seeds a real, statement-rich intra slice.
line: 912,
},
{
name: 'pdg-dispatch-upstream',
target: '_impactImpl',
file_path: 'gitnexus/src/mcp/local/local-backend.ts',
kind: 'Method',
direction: 'upstream',
line: 4427,
},
{
name: 'pdg-compose-downstream',
target: 'composeUnifiedPdgImpactResult',
file_path: 'gitnexus/src/mcp/local/local-backend.ts',
kind: 'Method',
direction: 'downstream',
line: 4850,
},
];
function readOption(argv, name, fallback = undefined) {
const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const idx = argv.indexOf(`--${name}`);
if (idx >= 0 && idx + 1 < argv.length) return argv[idx + 1];
return fallback;
}
function hasFlag(argv, name) {
return argv.includes(`--${name}`);
}
export function median(xs) {
if (xs.length === 0) return null;
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function percentile(xs, pct) {
if (xs.length === 0) return null;
const sorted = [...xs].sort((a, b) => a - b);
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((pct / 100) * sorted.length) - 1));
return sorted[idx];
}
function round(value, digits = 3) {
if (value === null || value === undefined || Number.isNaN(value)) return null;
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
function fmt(value, digits = 1) {
return value === null || value === undefined ? 'n/a' : Number(value).toFixed(digits);
}
export function symbolKeysFromByDepth(byDepth) {
const keys = new Set();
for (const items of Object.values(byDepth ?? {})) {
for (const item of items ?? []) {
if (!item || typeof item !== 'object') continue;
if (typeof item.id === 'string' && item.id.length > 0) {
keys.add(item.id);
continue;
}
const name = typeof item.name === 'string' ? item.name : '(unknown)';
const filePath = typeof item.filePath === 'string' ? item.filePath : '(unknown)';
keys.add(`${name}@${filePath}`);
}
}
return keys;
}
export function compareSymbolSets(reference, candidate) {
const ref = new Set(reference);
const cand = new Set(candidate);
const overlap = [...ref].filter((key) => cand.has(key));
const referenceOnly = [...ref].filter((key) => !cand.has(key)).sort();
const candidateOnly = [...cand].filter((key) => !ref.has(key)).sort();
const unionSize = new Set([...ref, ...cand]).size;
return {
referenceSize: ref.size,
candidateSize: cand.size,
overlapSize: overlap.length,
recallVsReference: ref.size === 0 ? null : overlap.length / ref.size,
precisionVsReference: cand.size === 0 ? null : overlap.length / cand.size,
jaccard: unionSize === 0 ? null : overlap.length / unionSize,
referenceOnly,
candidateOnly,
};
}
function sumEvidenceCounts(results) {
const counts = {};
for (const result of results) {
const evidenceCounts =
result?.pdgInterprocedural?.evidenceCounts ??
result?.pdgEvidence?.interproceduralEvidenceCounts ??
{};
for (const [key, value] of Object.entries(evidenceCounts)) {
counts[key] = (counts[key] ?? 0) + Number(value ?? 0);
}
}
return counts;
}
function readCases(caseFile) {
if (!caseFile) return DEFAULT_REAL_CODE_CASES;
const resolved = path.resolve(process.cwd(), caseFile);
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
const cases = Array.isArray(parsed) ? parsed : parsed.cases;
if (!Array.isArray(cases) || cases.length === 0) {
throw new Error(`case file ${resolved} must contain a non-empty array or { "cases": [...] }`);
}
return cases;
}
async function timedImpact(backend, params) {
const started = performance.now();
const result = await backend.callTool('impact', params);
return { result, ms: performance.now() - started };
}
async function measureCase(backend, testCase, options) {
const baseParams = {
repo: options.repo,
target: testCase.target,
file_path: testCase.file_path,
kind: testCase.kind,
direction: testCase.direction ?? 'upstream',
maxDepth: options.depth,
includeTests: options.includeTests,
limit: options.limit,
};
const callgraphTimes = [];
const pdgTimes = [];
let callgraphResult = null;
let pdgResult = null;
for (let i = 0; i < options.repeat; i++) {
const callgraph = await timedImpact(backend, { ...baseParams, mode: 'callgraph' });
callgraphTimes.push(callgraph.ms);
callgraphResult = callgraph.result;
const pdg = await timedImpact(backend, {
...baseParams,
mode: 'pdg',
...(Number.isInteger(testCase.line) ? { line: testCase.line } : {}),
});
pdgTimes.push(pdg.ms);
pdgResult = pdg.result;
}
const callgraphKeys = symbolKeysFromByDepth(callgraphResult?.byDepth ?? {});
const pdgInterByDepth =
pdgResult?.interproceduralByDepth ?? pdgResult?.pdgInterprocedural?.byDepth ?? {};
const pdgInterKeys = symbolKeysFromByDepth(pdgInterByDepth);
const symbolAgreement = compareSymbolSets(callgraphKeys, pdgInterKeys);
const evidenceCounts =
pdgResult?.pdgInterprocedural?.evidenceCounts ??
pdgResult?.pdgEvidence?.interproceduralEvidenceCounts ??
{};
return {
name: testCase.name ?? testCase.target,
target: testCase.target,
filePath: testCase.file_path,
kind: testCase.kind,
direction: baseParams.direction,
line: Number.isInteger(testCase.line) ? testCase.line : null,
latencyMs: {
callgraph: {
median: round(median(callgraphTimes)),
p95: round(percentile(callgraphTimes, 95)),
samples: callgraphTimes.map((v) => round(v)),
},
pdg: {
median: round(median(pdgTimes)),
p95: round(percentile(pdgTimes, 95)),
samples: pdgTimes.map((v) => round(v)),
},
pdgOverCallgraphMedian:
median(callgraphTimes) && median(callgraphTimes) > 0
? round(median(pdgTimes) / median(callgraphTimes))
: null,
},
callgraph: {
error: callgraphResult?.error ?? null,
impactedCount: callgraphResult?.impactedCount ?? 0,
risk: callgraphResult?.risk ?? null,
epistemic: callgraphResult?.epistemic ?? null,
partial: Boolean(callgraphResult?.partial),
symbolCount: callgraphKeys.size,
},
pdg: {
error: pdgResult?.error ?? null,
pdgLayer: pdgResult?.pdgLayer ?? 'ready',
epistemic: pdgResult?.epistemic ?? null,
partial: Boolean(pdgResult?.partial || pdgResult?.pdgInterprocedural?.partial),
impactedCount: pdgResult?.impactedCount ?? 0,
affectedStatementCount: pdgResult?.affectedStatementCount ?? 0,
blockCount: pdgResult?.blockCount ?? 0,
interproceduralSymbolCount: pdgInterKeys.size,
evidence: pdgResult?.pdgInterprocedural?.evidence ?? pdgResult?.pdgEvidence?.interprocedural,
evidenceCounts,
},
symbolAgreement,
};
}
export function summarizeCases(cases) {
const ratios = cases
.map((c) => c.latencyMs.pdgOverCallgraphMedian)
.filter((v) => v !== null && v !== undefined);
const callgraphMedians = cases
.map((c) => c.latencyMs.callgraph.median)
.filter((v) => v !== null && v !== undefined);
const pdgMedians = cases
.map((c) => c.latencyMs.pdg.median)
.filter((v) => v !== null && v !== undefined);
const comparable = cases.filter((c) => c.symbolAgreement.recallVsReference !== null);
const recalls = comparable.map((c) => c.symbolAgreement.recallVsReference);
const precisions = comparable
.map((c) => c.symbolAgreement.precisionVsReference)
.filter((v) => v !== null && v !== undefined);
const degradedCases = cases.filter((c) => c.pdg.pdgLayer !== 'ready');
const errorCases = cases.filter((c) => c.callgraph.error || c.pdg.error);
const partialCases = cases.filter((c) => c.callgraph.partial || c.pdg.partial);
const noBlockAtLineCases = cases.filter((c) => c.pdg.epistemic === 'pdg-no-block-at-line');
const evidenceCounts = sumEvidenceCounts(cases.map((c) => ({ pdgInterprocedural: c.pdg })));
const totalBridgeSymbols = Object.values(evidenceCounts).reduce((a, b) => a + Number(b ?? 0), 0);
return {
performance: {
callgraphMedianMs: round(median(callgraphMedians)),
pdgMedianMs: round(median(pdgMedians)),
pdgP95Ms: round(percentile(cases.map((c) => c.latencyMs.pdg.p95).filter(Boolean), 95)),
pdgOverCallgraphMedian: round(median(ratios)),
},
qualityProxy: {
comparableCases: comparable.length,
meanSymbolRecallVsCallgraph: recalls.length
? round(recalls.reduce((a, b) => a + b, 0) / recalls.length)
: null,
minSymbolRecallVsCallgraph: recalls.length ? round(Math.min(...recalls)) : null,
meanSymbolPrecisionVsCallgraph: precisions.length
? round(precisions.reduce((a, b) => a + b, 0) / precisions.length)
: null,
degradedCaseCount: degradedCases.length,
errorCaseCount: errorCases.length,
partialCaseCount: partialCases.length,
noBlockAtLineCaseCount: noBlockAtLineCases.length,
evidenceCounts,
unprovenBridgeRatio:
totalBridgeSymbols > 0
? round((evidenceCounts['unproven-bridge'] ?? 0) / totalBridgeSymbols)
: null,
},
};
}
export function evaluateCheckGates(report, env = process.env) {
const failures = [];
const minRecall = Number(env.GN_REAL_CODE_PDG_MIN_SYMBOL_RECALL ?? 0.95);
const maxMedianMs = Number(env.GN_REAL_CODE_PDG_MAX_MEDIAN_MS ?? 5000);
const quality = report.summary.qualityProxy;
const perf = report.summary.performance;
if (report.cases.length === 0) failures.push('no real-code cases were measured');
if (quality.errorCaseCount > 0)
failures.push(`${quality.errorCaseCount} case(s) returned errors`);
if (quality.degradedCaseCount > 0) {
failures.push(`${quality.degradedCaseCount} case(s) reported a degraded PDG layer`);
}
if (
quality.minSymbolRecallVsCallgraph !== null &&
quality.minSymbolRecallVsCallgraph < minRecall
) {
failures.push(
`min PDG symbol recall vs callgraph ${quality.minSymbolRecallVsCallgraph} < ${minRecall}`,
);
}
if (perf.pdgMedianMs !== null && perf.pdgMedianMs > maxMedianMs) {
failures.push(`PDG median latency ${perf.pdgMedianMs}ms > ${maxMedianMs}ms`);
}
return failures;
}
function renderText(report, failures) {
const lines = [];
const perf = report.summary.performance;
const quality = report.summary.qualityProxy;
lines.push('=== impact-PDG real-code performance/quality probe ===');
lines.push(
`repo ${report.repo} | cases ${report.cases.length} | repeat ${report.repeat} | includeTests=${report.includeTests}`,
);
lines.push('');
lines.push(
`Latency: callgraph median ${fmt(perf.callgraphMedianMs)}ms, ` +
`pdg median ${fmt(perf.pdgMedianMs)}ms, pdg p95 ${fmt(perf.pdgP95Ms)}ms, ` +
`median overhead ${fmt(perf.pdgOverCallgraphMedian, 2)}x`,
);
lines.push(
`Quality proxy: min PDG symbol recall vs callgraph ${fmt(
quality.minSymbolRecallVsCallgraph,
3,
)}, mean recall ${fmt(quality.meanSymbolRecallVsCallgraph, 3)}, ` +
`mean precision ${fmt(quality.meanSymbolPrecisionVsCallgraph, 3)}`,
);
lines.push(
`Signals: degraded=${quality.degradedCaseCount}, errors=${quality.errorCaseCount}, ` +
`partial=${quality.partialCaseCount}, no-block-at-line=${quality.noBlockAtLineCaseCount}, ` +
`unprovenBridgeRatio=${fmt(quality.unprovenBridgeRatio, 3)}`,
);
lines.push(`Evidence counts: ${JSON.stringify(quality.evidenceCounts)}`);
lines.push('');
lines.push('Per case:');
for (const c of report.cases) {
lines.push(
` ${c.name}: cg ${fmt(c.latencyMs.callgraph.median)}ms/${c.callgraph.symbolCount} symbols, ` +
`pdg ${fmt(c.latencyMs.pdg.median)}ms/${c.pdg.interproceduralSymbolCount} inter-symbols, ` +
`statements=${c.pdg.affectedStatementCount}, recall=${fmt(
c.symbolAgreement.recallVsReference,
3,
)}, precision=${fmt(c.symbolAgreement.precisionVsReference, 3)}, ` +
`evidence=${c.pdg.evidence ?? 'n/a'}`,
);
if (c.callgraph.error || c.pdg.error || c.pdg.pdgLayer !== 'ready') {
lines.push(
` status: callgraphError=${c.callgraph.error ?? 'none'} pdgError=${
c.pdg.error ?? 'none'
} pdgLayer=${c.pdg.pdgLayer}`,
);
}
if (c.symbolAgreement.referenceOnly.length > 0) {
lines.push(
` callgraph-only symbols: ${c.symbolAgreement.referenceOnly.slice(0, 5).join(', ')}`,
);
}
}
lines.push('');
lines.push(
'Interpretation: this real-code probe measures latency and quality proxies, not accuracy. ' +
'The curated fixture harness remains the AIS-backed accuracy gate.',
);
if (failures.length > 0) {
lines.push('');
for (const failure of failures) lines.push(`[impact-pdg-real-code --check] FAIL: ${failure}`);
}
return lines.join('\n');
}
async function run() {
const argv = process.argv.slice(2);
const repo = readOption(argv, 'repo', 'GitNexus');
const repeat = Math.max(1, Number(readOption(argv, 'repeat', '3')));
const depth = Math.max(1, Number(readOption(argv, 'depth', '3')));
const limit = Math.max(1, Number(readOption(argv, 'limit', '100')));
const includeTests = readOption(argv, 'include-tests', 'true') !== 'false';
const caseFile = readOption(argv, 'case-file');
const json = hasFlag(argv, 'json');
const check = hasFlag(argv, 'check');
const cases = readCases(caseFile);
const { LocalBackend } = await import(
path.join(REPO_ROOT, 'src', 'mcp', 'local', 'local-backend.ts')
);
const backend = new LocalBackend();
const initialized = await backend.init();
if (!initialized)
throw new Error('no indexed repositories found; run gitnexus analyze --pdg first');
try {
const measured = [];
for (const testCase of cases) {
measured.push(
await measureCase(backend, testCase, { repo, repeat, depth, limit, includeTests }),
);
}
const report = {
repo,
repeat,
depth,
limit,
includeTests,
generatedAt: new Date().toISOString(),
note: 'Real-code probe: latency plus quality proxies only. Accuracy requires AIS-backed fixtures.',
cases: measured,
summary: summarizeCases(measured),
};
const failures = evaluateCheckGates(report);
if (json) {
process.stdout.write(JSON.stringify({ ...report, checkFailures: failures }, null, 2) + '\n');
} else {
process.stdout.write(renderText(report, failures) + '\n');
}
if (check && failures.length > 0) process.exit(1);
} finally {
await backend.dispose().catch(() => {});
}
}
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
run().catch((err) => {
process.stderr.write(`[impact-pdg-real-code] ERROR: ${err?.stack || err}\n`);
process.exit(1);
});
}