193 lines
6.2 KiB
JavaScript
193 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Deterministically combines Vercel metric collection with the local codebase
|
|
// scan. Keeps the merged artifact shape stable: collect-signals output at the
|
|
// top level, scan-codebase output under `codebase`.
|
|
|
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { realpathSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { routePathMatchScore } from '../lib/investigation-brief.mjs';
|
|
import { canonicalizeRoute } from '../lib/route-normalize.mjs';
|
|
|
|
const log = (...args) => console.error('[merge-signals]', ...args);
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (!args.signalsPath || !args.codebasePath) {
|
|
console.error('usage: node scripts/merge-signals.mjs <signals.json> <codebase.json> [--out merged.json] [--force]');
|
|
process.exit(1);
|
|
}
|
|
|
|
const [signals, codebase] = await Promise.all([
|
|
readJson(args.signalsPath, 'signals'),
|
|
readJson(args.codebasePath, 'codebase scan'),
|
|
]);
|
|
|
|
const merged = mergeSignals(signals, codebase);
|
|
const body = JSON.stringify(merged, null, 2) + '\n';
|
|
if (args.outPath) {
|
|
await writeOutput(args.outPath, body, { force: args.force });
|
|
log(`wrote ${args.outPath}`);
|
|
} else {
|
|
process.stdout.write(body);
|
|
}
|
|
}
|
|
|
|
export function mergeSignals(signals, codebase) {
|
|
assertObject(signals, 'signals');
|
|
assertObject(codebase, 'codebase scan');
|
|
|
|
if (!signals.schemaVersion) {
|
|
throw new Error('signals.json is missing schemaVersion; pass collect-signals output as the first file.');
|
|
}
|
|
if (!Array.isArray(codebase.routes) || !Array.isArray(codebase.findings) || !codebase.stack) {
|
|
throw new Error('codebase.json must be scan-codebase output with stack, routes[], and findings[].');
|
|
}
|
|
|
|
return {
|
|
...signals,
|
|
codebase: annotateCodebaseScan(signals, codebase),
|
|
};
|
|
}
|
|
|
|
export function annotateCodebaseScan(signals, codebase) {
|
|
const index = buildRouteMetricIndex(signals);
|
|
return {
|
|
...codebase,
|
|
findings: (codebase.findings ?? []).map((finding) => annotateFinding(finding, index)),
|
|
};
|
|
}
|
|
|
|
function annotateFinding(finding, index) {
|
|
if (!finding || typeof finding !== 'object') return finding;
|
|
if (finding.trafficIndependent) return finding;
|
|
if (!finding.route) return { ...finding, o11ySignal: 'NO-ROUTE-MAPPING' };
|
|
|
|
const summary = bestRouteSummary(finding.route, index);
|
|
if (!summary || !hasTraffic(summary)) return { ...finding, o11ySignal: 'COLD-PATH' };
|
|
return { ...finding, o11ySignal: formatRouteSignal(summary) };
|
|
}
|
|
|
|
function buildRouteMetricIndex(signals) {
|
|
const out = new Map();
|
|
const ensure = (route) => {
|
|
const canonical = canonicalizeRoute(route);
|
|
const existing = out.get(canonical) ?? { route: canonical };
|
|
out.set(canonical, existing);
|
|
return existing;
|
|
};
|
|
|
|
for (const row of rows(signals, 'fnStatusByRoute')) {
|
|
if (!row.route) continue;
|
|
const summary = ensure(row.route);
|
|
summary.functionRuns = (summary.functionRuns ?? 0) + numeric(row.value);
|
|
}
|
|
for (const row of rows(signals, 'fnDurationP95ByRoute')) {
|
|
if (!row.route) continue;
|
|
ensure(row.route).p95Ms = numeric(row.value);
|
|
}
|
|
for (const row of rows(signals, 'requestsByRouteCache')) {
|
|
if (!row.route) continue;
|
|
const summary = ensure(row.route);
|
|
const count = numeric(row.value);
|
|
summary.requests = (summary.requests ?? 0) + count;
|
|
if (String(row.cache_result).toUpperCase() === 'HIT') {
|
|
summary.cacheHits = (summary.cacheHits ?? 0) + count;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rows(signals, metricId) {
|
|
const rows = signals?.metrics?.[metricId]?.rows;
|
|
return Array.isArray(rows) ? rows : [];
|
|
}
|
|
|
|
function numeric(value) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function bestRouteSummary(route, index) {
|
|
const canonical = canonicalizeRoute(route);
|
|
const exact = index.get(canonical);
|
|
if (exact) return exact;
|
|
|
|
let best = null;
|
|
for (const summary of index.values()) {
|
|
const score = routePathMatchScore(canonical, summary.route);
|
|
if (score <= 0) continue;
|
|
if (!best || score > best.score) best = { score, summary };
|
|
}
|
|
return best?.summary ?? null;
|
|
}
|
|
|
|
function hasTraffic(summary) {
|
|
return (summary.functionRuns ?? 0) > 0 || (summary.requests ?? 0) > 0;
|
|
}
|
|
|
|
function formatRouteSignal(summary) {
|
|
const parts = [];
|
|
if ((summary.functionRuns ?? 0) > 0) parts.push(`inv=${Math.round(summary.functionRuns)}`);
|
|
else if ((summary.requests ?? 0) > 0) parts.push(`requests=${Math.round(summary.requests)}`);
|
|
if ((summary.p95Ms ?? 0) > 0) parts.push(`p95=${Math.round(summary.p95Ms)}ms`);
|
|
if ((summary.requests ?? 0) > 0 && summary.cacheHits != null) {
|
|
const hitRate = Math.round((summary.cacheHits / summary.requests) * 100);
|
|
parts.push(`cache=${hitRate}%`);
|
|
}
|
|
return parts.join(',') || 'COLD-PATH';
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { positional: [], force: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--out') out.outPath = argv[++i];
|
|
else if (a.startsWith('--out=')) out.outPath = a.slice('--out='.length);
|
|
else if (a === '--force') out.force = true;
|
|
else out.positional.push(a);
|
|
}
|
|
out.signalsPath = out.positional[0];
|
|
out.codebasePath = out.positional[1];
|
|
return out;
|
|
}
|
|
|
|
async function readJson(path, label) {
|
|
try {
|
|
return JSON.parse(await readFile(path, 'utf-8'));
|
|
} catch (err) {
|
|
throw new Error(`Could not read ${label} JSON at ${path}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function assertObject(value, label) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(`${label} must be a JSON object.`);
|
|
}
|
|
}
|
|
|
|
async function writeOutput(path, body, { force }) {
|
|
if (!force && await exists(path)) {
|
|
throw new Error(`output file already exists: ${path}. Use a fresh run directory or pass --force to overwrite.`);
|
|
}
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, body);
|
|
}
|
|
|
|
async function exists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
main().catch((err) => {
|
|
console.error('[merge-signals] FAILED:', err.message);
|
|
process.exit(1);
|
|
});
|
|
}
|