import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { expectedScenarioIds, isScenarioExemptable } from './catalog.mjs'; import { CORE_SCENARIO_IDS } from './impact.mjs'; const TERMINAL_STATUSES = new Set([ 'passed', 'failed', 'not_applicable', 'skipped', 'blocked', 'unknown', ]); function iso(value) { return new Date(value).toISOString(); } export function createEnvironmentFingerprint(extra = {}) { const environment = { node: process.version, platform: process.platform, arch: process.arch, release: os.release(), ...extra, }; const fingerprint = crypto .createHash('sha256') .update(JSON.stringify(environment)) .digest('hex'); return { environment, fingerprint }; } export function summarizeScenarios(scenarios) { const summary = { required: scenarios.length, passed: 0, not_applicable: 0, failed: 0, skipped: 0, blocked: 0, unknown: 0, cleanup_failed: 0, }; for (const scenario of scenarios) { if (!TERMINAL_STATUSES.has(scenario.status)) { throw new Error(`Invalid scenario status for ${scenario.id}: ${scenario.status}`); } summary[scenario.status] += 1; if (scenario.cleanup_status === 'failed') { summary.cleanup_failed += 1; } } return summary; } export function createGateReport({ commitSha, branch, artifactSha256, artifact, scenarios, mode = 'all', startedAt = new Date(), completedAt = new Date(), maxAgeMs = 4 * 60 * 60 * 1000, environment = createEnvironmentFingerprint(), selection = null, }) { const summary = summarizeScenarios(scenarios); const report = { schema_version: 1, mode, commit_sha: commitSha, branch, artifact_sha256: artifactSha256, artifact, started_at: iso(startedAt), completed_at: iso(completedAt), expires_at: iso(new Date(new Date(completedAt).getTime() + maxAgeMs)), environment: environment.environment, environment_fingerprint: environment.fingerprint, summary, scenarios, exemptions: scenarios .filter((scenario) => scenario.status === 'not_applicable') .map((scenario) => scenario.exemption), approved_for_release: false, }; if (selection) report.selection = selection; return report; } function validateExemption(scenario, commitSha, errors) { if (!isScenarioExemptable(scenario.id)) { errors.push(`${scenario.id} is never exemptable`); return; } const exemption = scenario.exemption; if (!exemption || exemption.scenario_id !== scenario.id) { errors.push(`${scenario.id} exemption is missing or mismatched`); return; } if (exemption.commit_sha !== commitSha) { errors.push(`${scenario.id} exemption commit does not match report`); } for (const field of ['reason', 'reviewed_by', 'reviewed_at']) { if (!String(exemption[field] ?? '').trim()) { errors.push(`${scenario.id} exemption is missing ${field}`); } } for (const field of ['changed_paths_checked', 'dependency_evidence']) { if (!Array.isArray(exemption[field]) || exemption[field].length === 0) { errors.push(`${scenario.id} exemption is missing ${field}`); } } if (exemption.reviewed_at && Number.isNaN(Date.parse(exemption.reviewed_at))) { errors.push(`${scenario.id} exemption reviewed_at is invalid`); } } function sameValues(left, right) { if (!Array.isArray(left) || !Array.isArray(right)) return false; if (left.length !== right.length) return false; const leftSorted = [...left].sort(); const rightSorted = [...right].sort(); return leftSorted.every((value, index) => value === rightSorted[index]); } function validateImpactSelection(report, scenarioIds, errors) { const selection = report?.selection; if (!selection || typeof selection !== 'object') { errors.push('impact report is missing selection metadata'); return; } if (![1, 2].includes(selection.policy_version)) { errors.push('impact policy_version must be 1 or 2'); } const allowedStrategies = selection.policy_version === 2 ? ['core', 'impact'] : ['core', 'impact', 'full']; if (!allowedStrategies.includes(selection.strategy)) { errors.push( selection.policy_version === 2 ? 'impact strategy must be core or impact' : 'impact strategy must be core, impact, or full', ); } if (!/^[0-9a-f]{40}$/i.test(selection.base_commit ?? '')) { errors.push('impact base_commit must be a full SHA'); } if (selection.catalog_total !== expectedScenarioIds().length) { errors.push(`impact catalog_total must be ${expectedScenarioIds().length}`); } for (const field of [ 'core_ids', 'impact_groups', 'changed_paths', 'unmapped_paths', 'full_gate_reasons', 'selected_ids', ]) { if (!Array.isArray(selection[field])) errors.push(`impact ${field} must be an array`); } if (!sameValues(selection.core_ids, CORE_SCENARIO_IDS)) { errors.push('impact core_ids do not match the release policy'); } if (!sameValues(selection.selected_ids, scenarioIds)) { errors.push('impact selected_ids do not match report scenarios'); } if (selection.selected_total !== scenarioIds.length) { errors.push('impact selected_total does not match report scenarios'); } const missingCore = CORE_SCENARIO_IDS.filter((id) => !scenarioIds.includes(id)); if (missingCore.length > 0) { errors.push(`impact report is missing core scenarios: ${missingCore.join(',')}`); } if (selection.strategy === 'core' && selection.impact_groups?.length > 0) { errors.push('core impact report cannot contain impact groups'); } if (selection.strategy === 'impact' && selection.impact_groups?.length === 0) { errors.push('impact report must contain at least one impact group'); } if (selection.unmapped_paths?.length > 0) { errors.push( selection.policy_version === 2 ? 'unmapped runtime paths block the impact Gate' : 'unmapped runtime paths require a full Gate', ); } if (selection.strategy === 'full') { if (!sameValues(scenarioIds, expectedScenarioIds())) { errors.push('full impact strategy must execute the complete catalog'); } if (selection.full_gate_reasons?.length === 0) { errors.push('full impact strategy is missing its reason'); } } else if (selection.full_gate_reasons?.length > 0) { errors.push('non-full impact strategy cannot contain full Gate reasons'); } } export function validateGateReport(report, { expectedCommit, expectedArtifactSha256, expectedBranch = 'main', now = new Date(), requireFullCatalog = true, allowExpired = false, } = {}) { const errors = []; if (report?.schema_version !== 1) errors.push('schema_version must be 1'); if (!['all', 'incremental', 'impact'].includes(report?.mode) && requireFullCatalog) { errors.push('release report mode must be all, incremental, or impact'); } if (report?.mode === 'incremental') { const baseline = report?.baseline; if (!baseline || typeof baseline !== 'object') { errors.push('incremental report is missing baseline metadata'); } else { for (const field of ['commit_sha', 'report_path', 'carried_forward', 'reexecuted']) { if (baseline[field] === undefined || baseline[field] === null || baseline[field] === '') { errors.push(`incremental baseline is missing ${field}`); } } if (Number(baseline.carried_forward) + Number(baseline.reexecuted) !== report?.scenarios?.length) { errors.push('incremental baseline counts do not match scenario total'); } } } if (!/^[0-9a-f]{40}$/i.test(report?.commit_sha ?? '')) errors.push('commit_sha must be a full SHA'); if (expectedCommit && report?.commit_sha !== expectedCommit) errors.push('commit_sha does not match candidate'); if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`); if (!/^[0-9a-f]{64}$/i.test(report?.artifact_sha256 ?? '')) { errors.push('artifact_sha256 must be a SHA256'); } if (expectedArtifactSha256 && report?.artifact_sha256 !== expectedArtifactSha256) { errors.push('artifact_sha256 does not match candidate artifact'); } if (!/^[0-9a-f]{64}$/i.test(report?.environment_fingerprint ?? '')) { errors.push('environment_fingerprint must be a SHA256'); } const completedAt = Date.parse(report?.completed_at ?? ''); const expiresAt = Date.parse(report?.expires_at ?? ''); if (!Number.isFinite(completedAt) || !Number.isFinite(expiresAt)) { errors.push('report timestamps are invalid'); } else { if (expiresAt <= completedAt) errors.push('report expiry must be after completion'); if (expiresAt - completedAt > 4 * 60 * 60 * 1000) errors.push('report validity exceeds four hours'); if (!allowExpired && new Date(now).getTime() > expiresAt) errors.push('report has expired'); } if (!Array.isArray(report?.scenarios)) { errors.push('scenarios must be an array'); } else { const ids = report.scenarios.map((scenario) => scenario.id); const unique = new Set(ids); const fullCatalogRequired = requireFullCatalog && report.mode !== 'impact'; if (fullCatalogRequired) { const expected = expectedScenarioIds(); const expectedSet = new Set(expected); if (ids.length !== expected.length || unique.size !== expected.length) { errors.push(`report must contain ${expected.length} unique scenarios`); } const missing = expected.filter((id) => !unique.has(id)); const unexpected = ids.filter((id) => !expectedSet.has(id)); if (missing.length) errors.push(`report is missing scenarios: ${missing.join(',')}`); if (unexpected.length) errors.push(`report has unexpected scenarios: ${unexpected.join(',')}`); } if (report.mode === 'impact') validateImpactSelection(report, ids, errors); for (const scenario of report.scenarios) { if (!TERMINAL_STATUSES.has(scenario.status)) { errors.push(`${scenario.id} has invalid status ${scenario.status}`); continue; } if (scenario.status === 'not_applicable') { if (report.mode === 'impact') { errors.push(`${scenario.id} must execute when selected by the impact Gate`); } else { validateExemption(scenario, report.commit_sha, errors); } } else if (scenario.status !== 'passed') { errors.push(`${scenario.id} is ${scenario.status}`); } if (scenario.cleanup_status === 'failed') { errors.push(`${scenario.id} cleanup failed`); } } try { const actualSummary = summarizeScenarios(report.scenarios); if (JSON.stringify(actualSummary) !== JSON.stringify(report.summary)) { errors.push('summary does not match scenario statuses'); } if (actualSummary.failed || actualSummary.skipped || actualSummary.blocked || actualSummary.unknown || actualSummary.cleanup_failed) { errors.push('report contains blocking results'); } if (actualSummary.passed + actualSummary.not_applicable !== actualSummary.required) { errors.push('passed + not_applicable must equal required'); } } catch (error) { errors.push(error.message); } } if (!Array.isArray(report?.exemptions)) errors.push('exemptions must be an array'); return { valid: errors.length === 0, errors }; } function renderMarkdown(report) { const lines = [ '# Production Release Gate Report', '', `- Mode: ${report.mode}`, `- Commit: \`${report.commit_sha}\``, `- Branch: \`${report.branch}\``, `- Artifact SHA256: \`${report.artifact_sha256}\``, `- Completed: ${report.completed_at}`, `- Expires: ${report.expires_at}`, ...(report.selection ? [ `- Strategy: ${report.selection.strategy}`, `- Base commit: \`${report.selection.base_commit}\``, `- Selected: ${report.selection.selected_total}/${report.selection.catalog_total}`, `- Impact groups: ${report.selection.impact_groups.join(', ') || 'none'}`, ] : []), '', '| Result | Count |', '|---|---:|', ...Object.entries(report.summary).map(([key, value]) => `| ${key} | ${value} |`), '', '| Scenario | Status | Evidence |', '|---|---|---|', ...report.scenarios.map( (scenario) => `| ${scenario.id} | ${scenario.status} | ${scenario.evidence?.join('
') ?? ''} |`, ), '', ]; return lines.join('\n'); } function renderJunit(report) { const failures = report.scenarios.filter( (scenario) => !['passed', 'not_applicable'].includes(scenario.status), ).length; const cases = report.scenarios.map((scenario) => { const name = `${scenario.id} ${scenario.name ?? ''}`.replaceAll('"', '"'); if (scenario.status === 'passed') { return ` `; } if (scenario.status === 'not_applicable') { return ` `; } return ` `; }); return [ '', ``, ...cases, '', '', ].join('\n'); } export async function writeGateReport(report, outputDir) { await fs.mkdir(outputDir, { recursive: true }); await Promise.all([ fs.mkdir(path.join(outputDir, 'logs'), { recursive: true }), fs.mkdir(path.join(outputDir, 'scenarios'), { recursive: true }), fs.mkdir(path.join(outputDir, 'screenshots'), { recursive: true }), ]); await Promise.all([ fs.writeFile(path.join(outputDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`), fs.writeFile(path.join(outputDir, 'report.md'), renderMarkdown(report)), fs.writeFile(path.join(outputDir, 'junit.xml'), renderJunit(report)), fs.writeFile( path.join(outputDir, 'environment.json'), `${JSON.stringify({ fingerprint: report.environment_fingerprint, ...report.environment, }, null, 2)}\n`, ), fs.writeFile(path.join(outputDir, 'artifact.sha256'), `${report.artifact_sha256}\n`), ]); }