mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
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
457 lines
16 KiB
JavaScript
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);
|
|
});
|
|
}
|