#!/usr/bin/env node // Final pipeline step. Emits customer-facing markdown from // recommendations.json + gate.json + signals.json. import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { buildFinalReportMessage, renderReport } from '../lib/render-report.mjs'; import { dedupeRecommendations } from '../lib/dedup-recs.mjs'; import { canonicalizeRoute } from '../lib/route-normalize.mjs'; import { hasUnsupportedCacheLifeCdnText, splitCustomerSafeObservations } from '../lib/observation-safety.mjs'; const log = (...a) => console.error('[render-report]', ...a); const HARD_REGEN_TRIGGERS = new Set([ 'project_config_contradiction', 'cache_vary_safety', 'semantic_safety', ]); async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.recsPath || !args.gatePath || !args.signalsPath) { console.error('usage: node scripts/render-report.mjs [--project NAME] [--out FILE] [--message-out FILE] [--no-timestamp] [--debug-out FILE]'); process.exit(1); } const [recsRaw, gateRaw, signalsRaw] = await Promise.all([ readFile(args.recsPath, 'utf-8').then(JSON.parse), readFile(args.gatePath, 'utf-8').then(JSON.parse), readFile(args.signalsPath, 'utf-8').then(JSON.parse), ]); // Accept either a raw rec array OR the verify-and-regen wrapper // {recsGraded, qualityDropped, ...}. Stale-rec defense: when verify-and-regen // flagged a hard-safety issue but the orchestrator skipped re-spawn, the // original rec is still in recsGraded. Filter it here so it can't ship; it // surfaces in "Investigated, no change recommended" instead. const hardRegenRefs = new Set( Array.isArray(recsRaw.regenPlan) ? recsRaw.regenPlan .filter((p) => HARD_REGEN_TRIGGERS.has(p.regenTrigger)) .map((p) => p.candidateRef) .filter(Boolean) : [] ); const activeCandidates = [ ...(Array.isArray(gateRaw.toLaunch) ? gateRaw.toLaunch : []), ...(Array.isArray(gateRaw.platform) ? gateRaw.platform : []), ]; const enforceCurrentGate = !Array.isArray(recsRaw) && activeCandidates.length > 0; const staleRecommendationDrops = []; const wrapperRecommendations = Array.isArray(recsRaw.renderableRecommendations) ? recsRaw.renderableRecommendations : (recsRaw.recsGraded ?? []); const needsReviewDrops = []; const candidateRecommendations = Array.isArray(recsRaw) ? recsRaw.filter((r) => r?.abstain !== true) : wrapperRecommendations .filter((r, i) => (r.quality?.overall ?? 0) >= 0.55) .filter((r) => !hardRegenRefs.has(r.candidateRef)); const recommendationsRaw = candidateRecommendations .filter((r) => { if (r?.abstain === true || r?.needsReview !== true) return true; needsReviewDrops.push({ candidateRef: r.candidateRef ?? null, reason: 'This recommendation needs a manual safety review before it is ready to apply.', }); return false; }) .filter((r) => { if (!enforceCurrentGate) return true; if (recommendationMatchesActiveCandidate(r, activeCandidates)) return true; staleRecommendationDrops.push({ candidateRef: r.candidateRef ?? null, reason: 'This recommendation came from a candidate that is not in the current run output. Re-run from a clean run directory before applying it.', }); return false; }); const recommendations = dedupeRecommendations(recommendationsRaw); const readyTargets = new Set( recommendations .map((r) => candidateTarget(r?.candidateRef)) .filter(Boolean) ); const droppedContradictions = !Array.isArray(recsRaw) ? (recsRaw.recsGraded ?? []) .map((r, i) => ({ r, i })) .filter(({ r }) => hardRegenRefs.has(r.candidateRef)) .map(({ r, i }) => ({ candidateRef: r.candidateRef ?? null, reason: publicHardRegenReason(recsRaw.regenPlan?.find((p) => p.index === i || p.candidateRef === r.candidateRef)), })) : []; const gated = Array.isArray(gateRaw.gated) ? gateRaw.gated : []; // No-change findings are first-class investigation outputs ("the hypothesis didn't hold"). // Contradiction-dropped recs ride alongside them so customers see WHY a rec // was held back instead of it silently disappearing. const baseAbstentions = Array.isArray(recsRaw) ? recsRaw.filter((r) => r?.abstain === true).map((r) => ({ candidateRef: r.candidateRef ?? null, reason: publicNoChangeReason(r.reason ?? '(no reason recorded)'), })) : (recsRaw.abstentions ?? []).map((r) => ({ ...r, reason: publicNoChangeReason(r.reason ?? '(no reason recorded)'), })); const publicBaseAbstentions = baseAbstentions.filter((r) => !readyTargets.has(candidateTarget(r?.candidateRef))); // Observations: no-change findings carrying a structured non-perf finding // (deployment regression, error storm, etc.). const flattenedObservations = Array.isArray(recsRaw) ? flattenObservations(recsRaw.filter((r) => r?.abstain === true)) : flattenObservations([ ...(Array.isArray(recsRaw.observations) ? recsRaw.observations : []), ...(Array.isArray(recsRaw.abstentions) ? recsRaw.abstentions : []), ]); const { observations: safeObservations, heldBackObservations } = splitCustomerSafeObservations(flattenedObservations, baseAbstentions, signalsRaw); const observations = suppressReadyCoveredObservations(safeObservations, recommendations); const abstentions = [ ...publicBaseAbstentions, ...droppedContradictions, ...staleRecommendationDrops, ...needsReviewDrops, ...(Array.isArray(recsRaw.withheldRecommendations) ? recsRaw.withheldRecommendations.map((d) => ({ candidateRef: d.candidateRef ?? null, reason: publicWithheldReason(d), needsEvidence: true, })) : []), ...(Array.isArray(recsRaw.sanitizerDropped) ? recsRaw.sanitizerDropped.map((d) => ({ candidateRef: d.candidateRef ?? null, reason: `This needs a closer review before it is safe to apply: ${d.reason ?? 'review required'}.`, needsEvidence: true, })) : []), ...(Array.isArray(recsRaw.heldBackObservations) ? recsRaw.heldBackObservations.map((d) => ({ ...d, needsEvidence: true, })) : []), ...heldBackObservations, ]; // Full catalog lets the renderer recover o11ySignal + aliasRoutes that recs // didn't propagate, and canonicalize segment-tree candidateRefs. const allCandidates = [ ...activeCandidates, ...gated, ]; const md = renderReport({ recommendations, gated, abstentions, observations, signals: signalsRaw, candidates: allCandidates, opts: { projectName: args.projectName, generatedAt: args.noTimestamp ? null : new Date().toISOString(), heldBackCount: (Number.isInteger(recsRaw.summary?.withheldRecommendations) ? recsRaw.summary.withheldRecommendations : (Array.isArray(recsRaw.regenPlan) ? recsRaw.regenPlan.length : 0) + (Array.isArray(recsRaw.qualityDropped) ? recsRaw.qualityDropped.length : 0)) + (Array.isArray(recsRaw.sanitizerDropped) ? recsRaw.sanitizerDropped.length : 0) + (Array.isArray(recsRaw.heldBackObservations) ? recsRaw.heldBackObservations.length : 0) + heldBackObservations.length, noChangeCount: Number.isInteger(recsRaw.summary?.abstentions) ? Math.min(recsRaw.summary.abstentions, publicBaseAbstentions.length) : publicBaseAbstentions.length, }, }); if (args.debugOutPath) { const debugArtifact = buildDebugArtifact({ recsRaw, recommendationsRaw, recommendations, gateRaw, abstentions, observations, heldBackObservations, staleRecommendationDrops, droppedContradictions, }); const serializedDebug = JSON.stringify(debugArtifact, null, 2) + '\n'; await mkdir(dirname(args.debugOutPath), { recursive: true }); await writeFile(args.debugOutPath, serializedDebug, 'utf-8'); log(`wrote debug ${serializedDebug.length}B → ${args.debugOutPath}`); } if (args.messageOutPath) { const messageArtifact = buildFinalReportMessage({ reportPath: args.outPath ?? '(stdout)', markdown: md, recommendations, signals: signalsRaw, }); const serializedMessage = JSON.stringify(messageArtifact, null, 2) + '\n'; await mkdir(dirname(args.messageOutPath), { recursive: true }); await writeFile(args.messageOutPath, serializedMessage, 'utf-8'); log(`wrote final message ${serializedMessage.length}B → ${args.messageOutPath}`); } if (args.outPath) { await mkdir(dirname(args.outPath), { recursive: true }); await writeFile(args.outPath, md + '\n', 'utf-8'); log(`wrote ${md.length}B → ${args.outPath}`); } else { process.stdout.write(md + '\n'); } } function parseArgs(argv) { const out = { positional: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--project') out.projectName = argv[++i]; else if (a.startsWith('--project=')) out.projectName = a.slice('--project='.length); else if (a === '--out') out.outPath = resolve(argv[++i]); else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length)); else if (a === '--message-out') out.messageOutPath = resolve(argv[++i]); else if (a.startsWith('--message-out=')) out.messageOutPath = resolve(a.slice('--message-out='.length)); else if (a === '--no-timestamp') out.noTimestamp = true; else if (a === '--debug-out') out.debugOutPath = resolve(argv[++i]); else if (a.startsWith('--debug-out=')) out.debugOutPath = resolve(a.slice('--debug-out='.length)); else if (a === '--debug') { console.error('[render-report] --debug no longer writes internal details into customer markdown; use --debug-out FILE'); } else out.positional.push(a); } out.recsPath = out.positional[0]; out.gatePath = out.positional[1]; out.signalsPath = out.positional[2]; return out; } function publicWithheldReason(record) { switch (record?.reason) { case 'needs_review': return 'Automated checks added a safety caveat, so this run kept the recommendation out of the ready-to-apply list.'; case 'quality_floor': return 'The recommendation did not meet the evidence bar for this report.'; case 'project_config_contradiction': case 'cache_vary_safety': case 'semantic_safety': return publicHardRegenReason({ regenTrigger: record.reason }); default: return 'This recommendation needs stronger evidence before it is safe to apply.'; } } function publicHardRegenReason(plan) { switch (plan?.regenTrigger) { case 'project_config_contradiction': return 'The recommendation tried to turn on a project setting that is already enabled. Re-run the investigation with refreshed project-config evidence.'; case 'cache_vary_safety': return 'The recommendation added shared CDN caching to output that varies by request geography without the required Vary header. Re-run the investigation with the cache-safety failure in scope.'; case 'semantic_safety': return 'This recommendation needs stronger framework evidence before it is safe to apply. Re-run the investigation with that evidence in scope.'; default: return 'This recommendation needs stronger evidence before it is safe to apply. Re-run the investigation with those checks in scope.'; } } function recommendationMatchesActiveCandidate(rec, candidates) { const ref = parseCandidateRef(rec?.candidateRef); if (!ref) return true; return candidates.some((candidate) => candidateMatchesRef(candidate, ref)); } function parseCandidateRef(ref) { if (typeof ref !== 'string' || ref.length === 0) return null; const [kind, ...targetParts] = ref.split(':'); if (!kind) return null; return { kind, target: targetParts.join(':') }; } function candidateMatchesRef(candidate, ref) { if (!candidate || candidate.kind !== ref.kind) return false; if (candidate.scope === 'account' || ref.target === '') return true; const candidateTarget = candidate.route ?? candidate.hostname ?? candidate.file ?? candidate.target ?? null; if (!candidateTarget || !ref.target) return false; const a = String(candidateTarget); const b = String(ref.target); return a === b || canonicalizeRoute(a) === canonicalizeRoute(b); } function suppressReadyCoveredObservations(observations, recommendations = []) { if (!Array.isArray(observations) || observations.length === 0) return []; const readyFamiliesByTarget = new Map(); for (const rec of recommendations) { const parsed = parseCandidateRef(rec?.candidateRef); const target = candidateTarget(rec?.candidateRef); const family = candidateFamily(parsed?.kind); if (!target || !family) continue; const set = readyFamiliesByTarget.get(target) ?? new Set(); set.add(family); readyFamiliesByTarget.set(target, set); } return observations.filter((observation) => { const parsed = parseCandidateRef(observation?.candidateRef); const target = candidateTarget(observation?.candidateRef); const family = candidateFamily(parsed?.kind); if (!target || !family) return true; return !readyFamiliesByTarget.get(target)?.has(family); }); } function candidateFamily(kind) { switch (kind) { case 'uncached_route': case 'cache_header_gap': case 'missing_cache_headers': case 'max_age_without_s_maxage': return 'cache'; case 'slow_route': case 'cold_start': case 'external_api_slow': case 'cwv_poor': return 'performance'; case 'route_errors': return 'reliability'; case 'isr_overrevalidation': return 'isr'; case 'middleware_heavy': return 'middleware'; case 'build_minutes_fanout': return 'build'; default: return kind || null; } } function candidateTarget(ref) { if (typeof ref !== 'string') return null; const idx = ref.indexOf(':'); if (idx === -1) return null; return ref.slice(idx + 1); } function publicNoChangeReason(reason) { if (hasUnsupportedCacheLifeCdnText(reason)) { return 'This candidate overlapped a cache-lifetime draft that did not meet the framework evidence bar. No supported change shipped from this run.'; } return reason; } function buildDebugArtifact({ recsRaw, recommendationsRaw, recommendations, gateRaw, abstentions = [], observations = [], heldBackObservations = [], staleRecommendationDrops = [], droppedContradictions = [], }) { const wrapper = Array.isArray(recsRaw) ? null : recsRaw; const sourceRecords = Array.isArray(recsRaw) ? recsRaw : (recsRaw.recsGraded ?? []); const summary = wrapper?.summary ? { ...wrapper.summary, rawRecommendationCount: recommendationsRaw.length, renderedRecommendationCount: recommendations.length, } : null; return { schemaVersion: '1.0', summary, regenPlan: wrapper?.regenPlan ?? [], qualityDropped: wrapper?.qualityDropped ?? [], withheldRecommendations: wrapper?.withheldRecommendations ?? [], abstentions, observations, heldBackObservations, staleRecommendationDrops, droppedContradictions, sanitizerDropped: wrapper?.sanitizerDropped ?? [], renderedRecommendationCount: recommendations.length, rawRecommendationCount: recommendationsRaw.length, gateBudget: gateRaw?.budget ?? null, recommendations: sourceRecords .filter((record) => record && record.abstain !== true) .map((record) => ({ candidateRef: record.candidateRef ?? null, what: record.what ?? null, verification: record.verification ?? null, quality: record.quality ?? null, passRate: record.passRate ?? record.verification?.passRate ?? null, avgQuality: record.avgQuality ?? null, needsReview: record.needsReview === true, sanitizerTrail: Array.isArray(record.sanitizerTrail) ? record.sanitizerTrail : [], })), }; } function flattenObservations(records) { const out = []; for (const record of records) { if (!record || typeof record !== 'object') continue; if (record.observation && typeof record.observation === 'object') { out.push({ candidateRef: record.candidateRef ?? null, summary: coerceOptionalString(record.observation.summary), evidence: record.observation.evidence ?? null, suggestedAction: record.observation.suggestedAction ?? null, kind: record.observation.kind ?? 'other', }); continue; } if ('summary' in record || 'evidence' in record || 'suggestedAction' in record || 'kind' in record) { out.push({ candidateRef: record.candidateRef ?? null, summary: coerceOptionalString(record.summary), evidence: record.evidence ?? null, suggestedAction: record.suggestedAction ?? null, kind: record.kind ?? 'other', }); } } return out; } function coerceOptionalString(value) { return value == null ? value : String(value); } main().catch((err) => { console.error('[render-report] FAILED:', err.message); console.error(err.stack); process.exit(1); });