251 lines
8.0 KiB
JavaScript
Executable File
251 lines
8.0 KiB
JavaScript
Executable File
#!/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;
|
|
});
|