#!/usr/bin/env node import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const HELP = ` Mechanica validation evidence collector Usage: node scripts/collect-validation-evidence.mjs [options] node scripts/collect-validation-evidence.mjs [dest-dir] Options: --source Validation run directory. Default: .validation/latest --dest Evidence bundle output directory. --dest-base Parent directory for timestamped evidence bundles. Default: .validation/evidence --allow-failed Allow collecting a failed validation run. This is for debugging only and should not be used for final signoff. --include-dist Include the full dist/ build output in the bundle. By default the bundle keeps budget reports and selected manifest files instead of duplicating all built assets. --force Remove an existing destination directory first. --help Show this help text. `; function isTruthy(value) { return ['1', 'true', 'yes', 'y', 'on'].includes(String(value ?? '').toLowerCase()); } function timestampSlug(date = new Date()) { return date.toISOString().replaceAll(':', '-').replaceAll('.', '-'); } function posixPath(value) { return value.split(path.sep).join('/'); } function resolveFromRepo(value) { return path.resolve(repoRoot, value); } function parseArgs(argv) { const options = { source: process.env.MECHANICA_VALIDATION_SOURCE || '.validation/latest', dest: process.env.MECHANICA_EVIDENCE_DEST || null, destBase: process.env.MECHANICA_EVIDENCE_DEST_BASE || '.validation/evidence', allowFailed: isTruthy(process.env.MECHANICA_EVIDENCE_ALLOW_FAILED), includeDist: isTruthy(process.env.MECHANICA_EVIDENCE_INCLUDE_DIST), force: isTruthy(process.env.MECHANICA_EVIDENCE_FORCE), }; const positional = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help' || arg === '-h') { console.log(HELP.trim()); process.exit(0); } if (arg === '--allow-failed') { options.allowFailed = true; continue; } if (arg === '--include-dist') { options.includeDist = true; continue; } if (arg === '--force') { options.force = true; continue; } if (arg === '--source') { const value = argv[index + 1]; if (!value) { throw new Error('--source requires a path value'); } options.source = value; index += 1; continue; } if (arg.startsWith('--source=')) { options.source = arg.slice('--source='.length); continue; } if (arg === '--dest') { const value = argv[index + 1]; if (!value) { throw new Error('--dest requires a path value'); } options.dest = value; index += 1; continue; } if (arg.startsWith('--dest=')) { options.dest = arg.slice('--dest='.length); continue; } if (arg === '--dest-base') { const value = argv[index + 1]; if (!value) { throw new Error('--dest-base requires a path value'); } options.destBase = value; index += 1; continue; } if (arg.startsWith('--dest-base=')) { options.destBase = arg.slice('--dest-base='.length); continue; } if (arg.startsWith('-')) { throw new Error(`Unknown option: ${arg}`); } positional.push(arg); } if (positional[0]) { options.source = positional[0]; } if (positional[1]) { options.dest = positional[1]; } return options; } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function writeJson(filePath, value) { ensureDir(path.dirname(filePath)); fs.writeFileSync(`${filePath}.tmp`, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); fs.renameSync(`${filePath}.tmp`, filePath); } function writeText(filePath, value) { ensureDir(path.dirname(filePath)); fs.writeFileSync(`${filePath}.tmp`, value, 'utf8'); fs.renameSync(`${filePath}.tmp`, filePath); } function isSubPath(child, parent) { const relative = path.relative(parent, child); return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative)); } function relativeToRepo(filePath) { const relative = path.relative(repoRoot, filePath); return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? posixPath(relative) : filePath; } function describePath(filePath) { const resolved = path.resolve(filePath); return relativeToRepo(resolved); } function copyRecursive(source, destination) { ensureDir(path.dirname(destination)); fs.cpSync(source, destination, { recursive: true, force: true, errorOnExist: false, preserveTimestamps: true, }); } function directorySize(entryPath) { if (!fs.existsSync(entryPath)) { return 0; } const stat = fs.statSync(entryPath); if (stat.isFile()) { return stat.size; } if (!stat.isDirectory()) { return 0; } return fs.readdirSync(entryPath).reduce((total, entry) => { return total + directorySize(path.join(entryPath, entry)); }, 0); } function copyArtifact({ source, destination, required, copiedArtifacts }) { if (!fs.existsSync(source)) { if (required) { throw new Error(`Required validation artifact is missing: ${describePath(source)}`); } return false; } copyRecursive(source, destination); copiedArtifacts.push({ source: describePath(source), destination: posixPath(path.relative(path.dirname(destination), destination)), bundlePath: posixPath(path.relative(path.resolve(destination, '..', '..'), destination)), bytes: directorySize(destination), required, }); return true; } function copyRequiredFromSource(sourceDir, destDir, relativePath, copiedArtifacts) { copyArtifact({ source: path.join(sourceDir, relativePath), destination: path.join(destDir, relativePath), required: true, copiedArtifacts, }); } function copyOptionalFromSource(sourceDir, destDir, relativePath, copiedArtifacts) { copyArtifact({ source: path.join(sourceDir, relativePath), destination: path.join(destDir, relativePath), required: false, copiedArtifacts, }); } function copyFirstExisting(candidates, destination, copiedArtifacts) { if (fs.existsSync(destination)) { return false; } for (const candidate of candidates) { if (!fs.existsSync(candidate)) { continue; } const resolvedCandidate = path.resolve(candidate); const resolvedDestination = path.resolve(destination); if (resolvedCandidate === resolvedDestination || isSubPath(resolvedCandidate, resolvedDestination)) { continue; } copyRecursive(candidate, destination); copiedArtifacts.push({ source: describePath(candidate), destination: posixPath(path.relative(path.dirname(destination), destination)), bundlePath: posixPath(path.relative(path.resolve(destination, '..', '..'), destination)), bytes: directorySize(destination), required: false, }); return true; } return false; } function gitOutput(args) { const git = process.platform === 'win32' ? 'git.exe' : 'git'; const result = spawnSync(git, args, { cwd: repoRoot, encoding: 'utf8', windowsHide: true, }); if (result.error || result.status !== 0) { return null; } return String(result.stdout || '').trim(); } function gitMetadata() { return { commit: gitOutput(['rev-parse', 'HEAD']) || 'unknown', branch: gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']) || 'unknown', dirtyFiles: (gitOutput(['status', '--short']) || '') .split('\n') .map((line) => line.trim()) .filter(Boolean), }; } function evidenceReadme({ generatedAt, sourceDir, summary, includeDist }) { return [ '# Mechanica Final Validation Evidence Bundle', '', `Generated: ${generatedAt}`, `Source validation run: \`${describePath(sourceDir)}\``, `Validation status: **${String(summary.status || 'unknown').toUpperCase()}**`, '', '## Contents', '', '- `validation-summary.md` / `validation-summary.json` — final gate status and logs index.', '- `bundle-budget.md` / `bundle-budget.json` — production build size budget output.', '- `environment.md` / `environment.json` — Node, npm, Git, and package-script snapshot.', '- `logs/` — per-gate stdout/stderr logs.', '- `playwright-report/`, `playwright-results/`, or `test-results/` — browser regression artifacts when generated.', '- `source-snapshot/` — relevant validation, CI, and deployment configuration files from the checkout.', includeDist ? '- `dist/` — full production build output captured because `--include-dist` was used.' : '- Full `dist/` assets are not duplicated by default; use `bundle-budget.json` for the authoritative asset inventory.', '', '## Verification', '', 'Run:', '', '```bash', 'node scripts/verify-validation-evidence.mjs ', '```', '', 'The verifier checks required gates, bundle budget status, Playwright evidence presence, and SHA-256 hashes listed in `evidence-manifest.json`.', '', ].join('\n'); } function listFilesRecursive(dir, root = dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const absolutePath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...listFilesRecursive(absolutePath, root)); continue; } if (entry.isFile()) { files.push(posixPath(path.relative(root, absolutePath))); } } return files; } function sha256File(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); stream.on('data', (chunk) => hash.update(chunk)); stream.on('error', reject); stream.on('end', () => resolve(hash.digest('hex'))); }); } async function buildManifestFileEntries(destDir) { const files = listFilesRecursive(destDir) .filter((relativePath) => !['evidence-manifest.json', 'checksums.sha256'].includes(relativePath)) .sort((a, b) => a.localeCompare(b)); const entries = []; for (const relativePath of files) { const absolutePath = path.join(destDir, relativePath); const stat = fs.statSync(absolutePath); entries.push({ path: relativePath, bytes: stat.size, sha256: await sha256File(absolutePath), }); } return entries; } function configSnapshotPaths() { return [ 'package.json', '.env.example', 'vite.config.ts', 'vitest.config.ts', 'playwright.config.ts', 'tsconfig.json', 'tsconfig.node.json', 'vercel.json', '.github/workflows/ci.yml', '.github/workflows/final-validation.yml', 'scripts/run-final-validation.mjs', 'scripts/collect-validation-evidence.mjs', 'scripts/verify-validation-evidence.mjs', 'docs/FINAL_VALIDATION_RUNBOOK.md', 'docs/CLEAN_CHECKOUT_VALIDATION.md', 'docs/VALIDATION_FAILURE_TRIAGE.md', 'docs/FINAL_VALIDATION_EVIDENCE_GUIDE.md', 'docs/VALIDATION_EVIDENCE_BUNDLE.md', 'docs/FINAL_VALIDATION_SIGNOFF_TEMPLATE.md', ]; } function selectedDistSnapshotPaths() { return [ 'dist/index.html', 'dist/.vite/manifest.json', 'dist/manifest.json', 'dist/robots.txt', 'dist/site.webmanifest', ]; } function writeChecksums(destDir, fileEntries) { const lines = fileEntries.map((entry) => `${entry.sha256} ${entry.path}`); writeText(path.join(destDir, 'checksums.sha256'), `${lines.join('\n')}\n`); } async function main() { const options = parseArgs(process.argv.slice(2)); const sourceDir = path.resolve(repoRoot, options.source); const destDir = path.resolve( repoRoot, options.dest || path.join(options.destBase, `mechanica-final-validation-${timestampSlug()}`), ); if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) { throw new Error(`Validation source directory does not exist: ${describePath(sourceDir)}`); } if (isSubPath(destDir, sourceDir)) { throw new Error('Evidence destination must not be inside the source validation run directory'); } const summaryPath = path.join(sourceDir, 'validation-summary.json'); if (!fs.existsSync(summaryPath)) { throw new Error(`Missing validation summary: ${describePath(summaryPath)}`); } const summary = readJson(summaryPath); if (summary.status !== 'passed' && !options.allowFailed) { throw new Error( `Refusing to collect failed validation evidence (status: ${summary.status}). Re-run after fixing failures, or use --allow-failed for debugging only.`, ); } if (fs.existsSync(destDir)) { if (!options.force) { throw new Error(`Evidence destination already exists: ${describePath(destDir)}. Use --force to replace it.`); } fs.rmSync(destDir, { recursive: true, force: true }); } ensureDir(destDir); const copiedArtifacts = []; copyRequiredFromSource(sourceDir, destDir, 'validation-summary.md', copiedArtifacts); copyRequiredFromSource(sourceDir, destDir, 'validation-summary.json', copiedArtifacts); copyRequiredFromSource(sourceDir, destDir, 'bundle-budget.md', copiedArtifacts); copyRequiredFromSource(sourceDir, destDir, 'bundle-budget.json', copiedArtifacts); copyRequiredFromSource(sourceDir, destDir, 'environment.md', copiedArtifacts); copyOptionalFromSource(sourceDir, destDir, 'environment.json', copiedArtifacts); copyRequiredFromSource(sourceDir, destDir, 'logs', copiedArtifacts); for (const artifact of ['playwright-report', 'playwright-results', 'test-results', 'blob-report', 'coverage']) { copyOptionalFromSource(sourceDir, destDir, artifact, copiedArtifacts); } copyFirstExisting( [path.join(sourceDir, 'playwright-report'), path.join(repoRoot, 'playwright-report')], path.join(destDir, 'playwright-report'), copiedArtifacts, ); copyFirstExisting( [path.join(sourceDir, 'playwright-results'), path.join(repoRoot, 'playwright-results')], path.join(destDir, 'playwright-results'), copiedArtifacts, ); copyFirstExisting( [path.join(sourceDir, 'test-results'), path.join(repoRoot, 'test-results')], path.join(destDir, 'test-results'), copiedArtifacts, ); copyFirstExisting( [path.join(sourceDir, 'blob-report'), path.join(repoRoot, 'blob-report')], path.join(destDir, 'blob-report'), copiedArtifacts, ); for (const relativePath of configSnapshotPaths()) { const source = path.join(repoRoot, relativePath); if (fs.existsSync(source)) { const destination = path.join(destDir, 'source-snapshot', relativePath); copyRecursive(source, destination); copiedArtifacts.push({ source: describePath(source), destination: posixPath(path.relative(destDir, destination)), bundlePath: posixPath(path.relative(destDir, destination)), bytes: directorySize(destination), required: false, }); } } if (options.includeDist) { const distSource = path.join(repoRoot, 'dist'); if (fs.existsSync(distSource)) { const destination = path.join(destDir, 'dist'); copyRecursive(distSource, destination); copiedArtifacts.push({ source: describePath(distSource), destination: 'dist', bundlePath: 'dist', bytes: directorySize(destination), required: false, }); } } else { for (const relativePath of selectedDistSnapshotPaths()) { const source = path.join(repoRoot, relativePath); if (fs.existsSync(source)) { const destination = path.join(destDir, relativePath); copyRecursive(source, destination); copiedArtifacts.push({ source: describePath(source), destination: posixPath(path.relative(destDir, destination)), bundlePath: posixPath(path.relative(destDir, destination)), bytes: directorySize(destination), required: false, }); } } } const generatedAt = new Date().toISOString(); writeText( path.join(destDir, 'EVIDENCE_README.md'), evidenceReadme({ generatedAt, sourceDir, summary, includeDist: options.includeDist, }), ); const fileEntries = await buildManifestFileEntries(destDir); const manifest = { schemaVersion: 1, project: 'Mechanica', generatedAt, sourceRunDirectory: describePath(sourceDir), evidenceDirectory: describePath(destDir), validationStatus: summary.status || 'unknown', validationGeneratedAt: summary.generatedAt || null, git: gitMetadata(), copiedArtifacts, files: fileEntries, }; writeJson(path.join(destDir, 'evidence-manifest.json'), manifest); writeChecksums(destDir, fileEntries); ensureDir(path.join(repoRoot, '.validation')); writeText(path.join(repoRoot, '.validation', 'latest-evidence-path.txt'), `${describePath(destDir)}\n`); console.log('Mechanica validation evidence bundle collected.'); console.log(`Bundle: ${describePath(destDir)}`); console.log(`Files hashed: ${fileEntries.length}`); console.log('Next: node scripts/verify-validation-evidence.mjs ' + shellPath(describePath(destDir))); } function shellPath(value) { return /\s/.test(value) ? JSON.stringify(value) : value; } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exitCode = 1; });