#!/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 <path> [--since origin/main] [--no-diff]',
' pnpm harness explain --spec <path> [--since origin/main]',
' pnpm harness run --spec <path> [--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;
});