#!/usr/bin/env node import process from 'node:process'; import { getChangedFiles } from './git.mjs'; import { PROFILES, selectSteps } from './profiles.mjs'; import { writeReport } from './report.mjs'; import { runStep } from './runner.mjs'; import { isGatewayBackendCommunicationTask, isPluginLifecycleTask, loadRuleSpecs, loadScenarioSpecs, loadSpec, toArray, } from './specs.mjs'; import { scanBackendCommunicationBoundary, touchesCommunicationPath, validateGatewayTaskSpec, validatePluginLifecycleTaskSpec, } from './rules.mjs'; function parseArgs(argv) { const args = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--') continue; if (!arg.startsWith('--')) { args._.push(arg); continue; } const key = arg.slice(2); const next = argv[i + 1]; if (!next || next.startsWith('--')) { args[key] = true; continue; } args[key] = next; i += 1; } return args; } function printUsage() { console.log([ 'Usage:', ' pnpm harness list', ' pnpm harness validate --spec [--since origin/main] [--no-diff]', ' pnpm harness explain --spec [--since origin/main]', ' pnpm harness run --spec [--since origin/main] [--dry-run] [--continue-on-error]', ].join('\n')); } async function findScenario(id) { const scenarios = await loadScenarioSpecs(); return scenarios.find((scenario) => scenario.data.id === id); } async function list() { const scenarios = await loadScenarioSpecs(); const rules = await loadRuleSpecs(); console.log('Profiles:'); for (const profile of Object.keys(PROFILES)) console.log(`- ${profile}`); console.log('\nScenario specs:'); for (const spec of scenarios) console.log(`- ${spec.data.id}: ${spec.path}`); console.log('\nRule specs:'); for (const spec of rules) console.log(`- ${spec.data.id}: ${spec.path}`); } async function validate(specPath, options = {}) { const spec = await loadSpec(specPath); const scenario = spec.data.scenario ? await findScenario(spec.data.scenario) : null; const shouldCheckDiff = !options.noDiff && (options.checkDiff || Boolean(spec.data.scenario)); const changedFiles = shouldCheckDiff ? await getChangedFiles(options.since ?? 'origin/main') : []; const failures = []; if (spec.data.type === 'runtime-bridge' && spec.data.id === 'gateway-backend-communication') { for (const profile of ['fast', 'comms']) { if (!toArray(spec.data.requiredProfiles).includes(profile)) { failures.push(`${spec.path}: requiredProfiles must include "${profile}"`); } } for (const rule of ['renderer-main-boundary', 'backend-communication-boundary', 'comms-regression', 'docs-sync']) { if (!toArray(spec.data.requiredRules).includes(rule)) { failures.push(`${spec.path}: requiredRules must include "${rule}"`); } } } else if (isGatewayBackendCommunicationTask(spec)) { failures.push(...validateGatewayTaskSpec(spec, scenario, changedFiles)); } else if (isPluginLifecycleTask(spec)) { failures.push(...validatePluginLifecycleTaskSpec(spec, scenario, changedFiles)); } else if (!spec.data.id || !spec.data.title) { failures.push(`${spec.path}: spec must include id and title`); } if (changedFiles.length > 0 && touchesCommunicationPath(changedFiles) && isGatewayBackendCommunicationTask(spec)) { const requiredProfiles = toArray(spec.data.requiredProfiles); if (!requiredProfiles.includes('comms')) { failures.push(`${spec.path}: communication path changes must require comms`); } } return { spec, scenario, changedFiles, failures }; } async function explain(specPath, options = {}) { const result = await validate(specPath, { ...options, checkDiff: Boolean(options.since) }); const requiredProfiles = toArray(result.spec.data.requiredProfiles); const scenarioProfiles = toArray(result.scenario?.data?.requiredProfiles); const profiles = [...new Set([...scenarioProfiles, ...requiredProfiles])]; console.log(`Spec: ${result.spec.path}`); console.log(`Scenario: ${result.spec.data.scenario ?? result.spec.data.id}`); console.log(`Task type: ${result.spec.data.taskType ?? result.spec.data.type ?? 'n/a'}`); console.log(`Required profiles: ${profiles.join(', ') || 'none'}`); if (result.changedFiles.length > 0) { console.log('\nChanged files:'); for (const file of result.changedFiles) console.log(`- ${file}`); } console.log('\nSelected steps:'); for (const step of selectSteps(profiles)) { console.log(`- [${step.profile}] ${step.command} ${step.args.join(' ')}`); } if (result.failures.length > 0) { console.log('\nValidation failures:'); for (const failure of result.failures) console.log(`- ${failure}`); } return result.failures.length === 0 ? 0 : 1; } async function run(specPath, options = {}) { const startedAt = new Date().toISOString(); const validation = await validate(specPath, { ...options, checkDiff: true }); const requiredProfiles = [ ...toArray(validation.scenario?.data?.requiredProfiles), ...toArray(validation.spec.data.requiredProfiles), ]; const profiles = [...new Set(requiredProfiles)]; const steps = []; const failures = [...validation.failures]; const scanFiles = [ ...validation.changedFiles, ...toArray(validation.spec.data.touchedAreas), ]; const boundaryFailures = await scanBackendCommunicationBoundary(scanFiles); failures.push(...boundaryFailures); steps.push({ profile: 'rules', name: 'Backend communication boundary scan', status: boundaryFailures.length === 0 ? 'pass' : 'fail', exitCode: boundaryFailures.length === 0 ? 0 : 1, durationMs: 0, }); const selectedSteps = selectSteps(profiles); if (failures.length === 0) { for (const step of selectedSteps) { if (options.dryRun) { steps.push({ ...step, status: 'skipped', exitCode: 0, durationMs: 0 }); continue; } const result = await runStep(step); steps.push(result); if (result.status !== 'pass') { failures.push(`${step.name} failed with exit code ${result.exitCode}`); if (!options.continueOnError) break; } } } else { for (const step of selectedSteps) { steps.push({ ...step, status: 'blocked', exitCode: 1, durationMs: 0 }); } } const report = { specPath: validation.spec.path, scenario: validation.spec.data.scenario ?? validation.spec.data.id, taskType: validation.spec.data.taskType ?? validation.spec.data.type ?? null, startedAt, finishedAt: new Date().toISOString(), changedFiles: validation.changedFiles, selectedProfiles: profiles, steps, failures, result: failures.length === 0 ? 'pass' : 'fail', }; const paths = await writeReport(report); console.log(`Harness report: ${paths.markdownPath}`); if (failures.length > 0) { console.error(failures.map((failure) => `- ${failure}`).join('\n')); return 1; } return 0; } async function main() { const args = parseArgs(process.argv.slice(2)); const command = args._[0]; if (!command || args.help) { printUsage(); return 0; } if (command === 'list') { await list(); return 0; } if (!args.spec) { printUsage(); return 1; } if (command === 'validate') { const result = await validate(args.spec, { since: args.since, checkDiff: Boolean(args.since), noDiff: Boolean(args['no-diff']), }); if (result.failures.length > 0) { console.error(result.failures.map((failure) => `- ${failure}`).join('\n')); return 1; } console.log(`Spec is valid: ${result.spec.path}`); return 0; } if (command === 'explain') { return await explain(args.spec, { since: args.since }); } if (command === 'run') { return await run(args.spec, { since: args.since, dryRun: Boolean(args['dry-run']), continueOnError: Boolean(args['continue-on-error']), }); } printUsage(); return 1; } main().then((exitCode) => { process.exitCode = exitCode; }).catch((error) => { console.error(error); process.exitCode = 1; });