import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join, resolve, relative } from 'node:path' import { spawn } from 'node:child_process' const root = resolve(import.meta.dirname, '..') const baseline = resolve(process.env.GATE4_BASELINE_WORKTREE ?? '/tmp/reams-gate4-baseline-a8577896') const candidateBuild = resolve('/tmp/reams-gate4-candidate-build') const baselineBuild = resolve('/tmp/reams-gate4-baseline-build') const candidatePort = 7491 const baselinePort = 7492 const commit = process.env.GATE4_BASELINE_REF ?? 'a8577896' const output = resolve(process.env.GATE4_OUTPUT ?? 'docs/verification/gate4-writer-perf.json') const runId = `${new Date().toISOString()}-${process.pid}` function digestFile(path) { return createHash('sha256').update(readFileSync(path)).digest('hex') } function treeHash(directory) { const hash = createHash('sha256') const visit = current => { for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'coverage' || entry.name === 'dist' || entry.name === 'test-results' || entry.name === '.perf-artifacts' || entry.name === 'gate4-writer-perf.json') continue const path = join(current, entry.name) const name = relative(directory, path) if (entry.isDirectory()) visit(path) else if (entry.isFile()) { hash.update(name); hash.update(readFileSync(path)) } } } visit(directory) return hash.digest('hex') } function sourceHash(directory) { const hash = createHash('sha256') const roots = ['src', 'public', 'index.html', 'package.json', 'package-lock.json', 'bun.lock', 'vite.config.ts', 'tsconfig.json'] const visit = (current, relativeRoot) => { for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name === 'node_modules' || entry.name === 'dist') continue const path = join(current, entry.name) const name = join(relativeRoot, entry.name) if (entry.isDirectory()) visit(path, name) else if (entry.isFile()) { hash.update(name); hash.update(readFileSync(path)) } } } for (const rootPath of roots) { const path = join(directory, rootPath) if (existsSync(path)) statSync(path).isDirectory() ? visit(path, rootPath) : (hash.update(rootPath), hash.update(readFileSync(path))) } return hash.digest('hex') } function lockHash(directory) { return createHash('sha256').update(['package-lock.json', 'bun.lock'].map(name => digestFile(join(directory, name))).join('\n')).digest('hex') } function run(command, args, cwd, env = process.env) { const child = spawn(command, args, { cwd, stdio: 'inherit', env }) return new Promise((resolvePromise, reject) => { child.once('error', reject); child.once('exit', code => code === 0 ? resolvePromise() : reject(new Error(`${command} ${args.join(' ')} exited ${code}`))) }) } async function prepareBaseline() { if (existsSync(join(baseline, '.git'))) await run('git', ['worktree', 'remove', '--force', baseline], root) mkdirSync(resolve(baseline, '..'), { recursive: true }); await run('git', ['worktree', 'add', '--detach', baseline, commit], root) if (lockHash(root) !== lockHash(baseline)) throw new Error(`Candidate and ${commit} lockfile hashes differ; refusing shared dependency resolution`) await run('npm', ['ci', '--ignore-scripts'], baseline) } async function build(cwd, outDir) { rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }) await run(process.execPath, ['node_modules/vite/bin/vite.js', 'build', '--outDir', outDir, '--emptyOutDir'], cwd, { ...process.env, VITE_DISABLE_WEBSOCKET_SYNC: '1', VITE_ENABLE_TEST_DIAGNOSTICS: '1' }) } function startPreview(cwd, outDir, port) { const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--strictPort', '--port', String(port), '--outDir', outDir], { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, VITE_DISABLE_WEBSOCKET_SYNC: '1', VITE_ENABLE_TEST_DIAGNOSTICS: '1' } }) const ready = new Promise((resolvePromise, reject) => { let output = '' const timer = setTimeout(() => reject(new Error(`Preview startup timed out: ${output}`)), 60_000) const onData = chunk => { output += chunk.toString(); if (output.includes(`http://127.0.0.1:${port}`)) { clearTimeout(timer); resolvePromise() } } child.stdout.on('data', onData); child.stderr.on('data', onData); child.once('error', reject); child.once('exit', code => reject(new Error(`Preview exited ${code}: ${output}`))) }) return { child, ready } } async function stop(server) { if (!server?.child || server.child.exitCode !== null) return server.child.kill('SIGTERM') await new Promise(resolvePromise => { const timer = setTimeout(() => { server.child.kill('SIGKILL'); resolvePromise() }, 5_000); server.child.once('exit', () => { clearTimeout(timer); resolvePromise() }) }) } async function main() { mkdirSync(dirname(output), { recursive: true }); try { unlinkSync(output) } catch {} let before; let candidate; let reference; let stage = 'prepare' const writeFailure = error => writeFileSync(output, JSON.stringify({ runId, status: 'failed', stage, error: error instanceof Error ? error.message : String(error), generatedAt: new Date().toISOString() }, null, 2) + '\n') try { await prepareBaseline() before = { candidate: sourceHash(root), baseline: sourceHash(baseline), lock: { candidate: lockHash(root), baseline: lockHash(baseline) } } stage = 'build'; await build(root, candidateBuild); await build(baseline, baselineBuild) stage = 'startup' candidate = startPreview(root, candidateBuild, candidatePort); reference = startPreview(baseline, baselineBuild, baselinePort) await Promise.all([candidate.ready, reference.ready]) stage = 'harness' const env = { ...process.env, RUN_GATE4_PERF: '1', GATE4_RUN_ID: runId, GATE4_CANDIDATE_URL: `http://127.0.0.1:${candidatePort}`, GATE4_BASELINE_URL: `http://127.0.0.1:${baselinePort}`, GATE4_BASELINE_ROOT: baseline, GATE4_SOURCE_HASHES: JSON.stringify({ before, candidateBuild: treeHash(candidateBuild), baselineBuild: treeHash(baselineBuild) }), GATE4_LOCK_HASHES: JSON.stringify(before.lock), GATE4_OUTPUT: output } await run('npx', ['vitest', 'run', '--config', 'vitest.config.perf.ts', 'tests/perf/gate4-writer-harness.perf.test.ts'], root, env) } catch (error) { if (!existsSync(output) || JSON.parse(readFileSync(output, 'utf8')).runId !== runId) writeFailure(error) throw error } finally { await Promise.all([stop(candidate), stop(reference)]) const after = before ? { candidate: sourceHash(root), baseline: sourceHash(baseline) } : null if (before && (after.candidate !== before.candidate || after.baseline !== before.baseline)) { const error = `Runtime source changed during measurement: ${JSON.stringify({ before, after })}`; writeFailure(new Error(error)); throw new Error(error) } if (existsSync(output)) { const evidence = JSON.parse(readFileSync(output, 'utf8')); if (evidence.runId === runId && after) { evidence.sourceHashes = { ...evidence.sourceHashes, after }; writeFileSync(output, JSON.stringify(evidence, null, 2) + '\n') } } } } if (process.env.GATE4_PREPARE_ONLY === '1') await prepareBaseline(); else await main()