173 lines
5.1 KiB
JavaScript
173 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { existsSync } from 'fs';
|
|
import { readFile } from 'fs/promises';
|
|
import { resolve } from 'path';
|
|
|
|
const TIMEOUT_MS = 10000;
|
|
const TIMEOUT_TOKEN = 'MEMTRACE_MCP_ERROR_TIMEOUT';
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage: node qa-memtrace.mjs --blast-radius <file.json> --test-coverage <file.json> [--threshold N]
|
|
|
|
Arguments:
|
|
--blast-radius <file> Path to JSON file with get_impact output
|
|
--test-coverage <file> Path to JSON file with test coverage data
|
|
--threshold N Coverage threshold 0-100 (default: 100)
|
|
|
|
Exit codes:
|
|
0 All required nodes covered (coverage >= threshold)
|
|
1 Coverage insufficient or error
|
|
|
|
The output JSON includes: status, blast_radius_total, covered_nodes,
|
|
uncovered_nodes, coverage_percentage, threshold, passed, uncovered_details.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const result = { blastRadius: null, testCoverage: null, threshold: 100 };
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--blast-radius' && i + 1 < args.length) {
|
|
result.blastRadius = args[++i];
|
|
} else if (args[i] === '--test-coverage' && i + 1 < args.length) {
|
|
result.testCoverage = args[++i];
|
|
} else if (args[i] === '--threshold' && i + 1 < args.length) {
|
|
result.threshold = parseInt(args[++i], 10);
|
|
if (isNaN(result.threshold) || result.threshold < 0 || result.threshold > 100) {
|
|
fail(`Invalid threshold. Must be integer 0-100.`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
fail(`Unknown argument: ${args[i]}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (!result.blastRadius) { fail('Missing --blast-radius'); process.exit(1); }
|
|
if (!result.testCoverage) { fail('Missing --test-coverage'); process.exit(1); }
|
|
|
|
return result;
|
|
}
|
|
|
|
function fail(msg) {
|
|
console.error(`ERROR: ${msg}`);
|
|
console.log(TIMEOUT_TOKEN);
|
|
}
|
|
|
|
async function readJsonFile(filePath) {
|
|
const resolved = resolve(filePath);
|
|
if (!existsSync(resolved)) {
|
|
throw new Error(`File not found: ${resolved}`);
|
|
}
|
|
return JSON.parse(await readFile(resolved, 'utf-8'));
|
|
}
|
|
|
|
function compute(blastData, coverageData, threshold) {
|
|
if (!Array.isArray(blastData.affected_symbols) || blastData.affected_symbols.length === 0) {
|
|
return {
|
|
status: 'pass',
|
|
blast_radius_total: 0,
|
|
covered_nodes: 0,
|
|
uncovered_nodes: 0,
|
|
coverage_percentage: 100,
|
|
threshold,
|
|
passed: true,
|
|
uncovered_details: [],
|
|
elapsed_ms: 0,
|
|
note: 'Empty blast radius — no nodes to intersect'
|
|
};
|
|
}
|
|
|
|
const blastSet = new Map();
|
|
for (const sym of blastData.affected_symbols) {
|
|
const key = `${sym.file}:${sym.name}`;
|
|
blastSet.set(key, sym);
|
|
}
|
|
|
|
const coveredSet = new Set();
|
|
for (const mod of coverageData.modules) {
|
|
const modPath = mod.module || '';
|
|
const cov = mod.coverage || '';
|
|
|
|
if (cov === 'Yes') {
|
|
for (const sym of (mod.symbols_covered || [])) {
|
|
if (blastSet.has(`${modPath}:${sym}`)) {
|
|
coveredSet.add(`${modPath}:${sym}`);
|
|
}
|
|
}
|
|
} else if ((mod.coverage || '').startsWith('Partial:')) {
|
|
const n = parseInt(cov.split(':')[1], 10) || 0;
|
|
const covered = (mod.symbols_covered || []).slice(0, n);
|
|
for (const sym of covered) {
|
|
if (blastSet.has(`${modPath}:${sym}`)) {
|
|
coveredSet.add(`${modPath}:${sym}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const uncoveredDetails = [];
|
|
for (const [key, sym] of blastSet) {
|
|
if (!coveredSet.has(key)) {
|
|
uncoveredDetails.push({ symbol: sym.name, file: sym.file, depth: sym.depth });
|
|
}
|
|
}
|
|
|
|
const total = blastSet.size;
|
|
const covered = coveredSet.size;
|
|
const uncovered = uncoveredDetails.length;
|
|
const pct = total > 0 ? (covered / total) * 100 : 100;
|
|
const passed = pct >= threshold;
|
|
|
|
return {
|
|
status: passed ? 'pass' : 'fail',
|
|
blast_radius_total: total,
|
|
covered_nodes: covered,
|
|
uncovered_nodes: uncovered,
|
|
coverage_percentage: Math.round(pct * 10) / 10,
|
|
threshold,
|
|
passed,
|
|
uncovered_details: uncoveredDetails,
|
|
elapsed_ms: 0
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs();
|
|
const start = Date.now();
|
|
|
|
const blastData = await readJsonFile(args.blastRadius);
|
|
if (!Array.isArray(blastData.affected_symbols)) {
|
|
throw new Error('Invalid blast-radius data: "affected_symbols" must be an array');
|
|
}
|
|
if (typeof blastData.total_count !== 'number') {
|
|
throw new Error('Invalid blast-radius data: "total_count" must be a number');
|
|
}
|
|
|
|
const coverageData = await readJsonFile(args.testCoverage);
|
|
if (!Array.isArray(coverageData.modules)) {
|
|
throw new Error('Invalid test-coverage data: "modules" must be an array');
|
|
}
|
|
|
|
const result = compute(blastData, coverageData, args.threshold);
|
|
result.elapsed_ms = Date.now() - start;
|
|
|
|
console.log(JSON.stringify(result, null, 2));
|
|
process.exit(result.passed ? 0 : 1);
|
|
}
|
|
|
|
const timeout = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('TIMEOUT')), TIMEOUT_MS)
|
|
);
|
|
|
|
Promise.race([main(), timeout])
|
|
.catch(err => {
|
|
if (err.message === 'TIMEOUT') {
|
|
console.log(TIMEOUT_TOKEN);
|
|
} else {
|
|
fail(err.message);
|
|
}
|
|
process.exit(1);
|
|
});
|