#!/usr/bin/env node import assert from "node:assert/strict"; import { access, readdir, readFile } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const reportsDir = path.join(repoRoot, "reports/dev-gate"); const requiredIssue = "pikasTech/HWLAB#31"; const requiredValidationCommands = [ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ]; const requiredSmokeCommand = "node scripts/m1-contract-smoke.mjs"; const requiredDryRunCommand = "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"; const requiredDocs = [ "docs/dev-acceptance-matrix.md", "docs/m0-contract-audit.md" ]; const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]); const blockerTypes = new Set([ "contract_blocker", "environment_blocker", "network_blocker", "runtime_blocker", "agent_blocker", "observability_blocker", "safety_blocker" ]); const blockerStates = new Set(["open", "acknowledged", "closed"]); function assertObject(value, label) { assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); } function assertString(value, label) { assert.equal(typeof value, "string", `${label} must be a string`); assert.ok(value.trim().length > 0, `${label} must not be empty`); assert.ok(!value.includes("\n"), `${label} must be a single line`); } function assertArray(value, label) { assert.ok(Array.isArray(value), `${label} must be an array`); } function assertStatus(value, label) { assertString(value, label); assert.ok(statusValues.has(value), `${label} must be one of ${Array.from(statusValues).join(", ")}`); } function assertRepoRelativePath(value, label) { assertString(value, label); assert.ok(!path.isAbsolute(value), `${label} must be repo-relative`); assert.ok(!value.startsWith(".."), `${label} must stay inside the repository`); const absolutePath = path.resolve(repoRoot, value); assert.ok( absolutePath.startsWith(`${repoRoot}${path.sep}`), `${label} must stay inside the repository` ); return absolutePath; } function assertStringArray(value, label, { minLength = 0 } = {}) { assertArray(value, label); assert.ok(value.length >= minLength, `${label} must contain at least ${minLength} item(s)`); for (const [index, item] of value.entries()) { assertString(item, `${label}[${index}]`); } } function assertUnique(values, label) { assert.equal(new Set(values).size, values.length, `${label} must be unique`); } async function readJsonFile(relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); return JSON.parse(raw); } async function collectReportFiles() { const entries = await readdir(reportsDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => path.join("reports/dev-gate", entry.name)) .sort(); } async function validateReport(relativePath) { const report = await readJsonFile(relativePath); const label = relativePath; assertObject(report, label); for (const field of [ "$schema", "$id", "reportVersion", "issue", "taskId", "commitId", "acceptanceLevel", "devOnly", "prodDisabled", "sourceContract", "validationCommands", "localSmoke", "dryRun", "devPreconditions", "blockers" ]) { assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); } assertString(report.$schema, `${label}.$schema`); assertString(report.$id, `${label}.$id`); assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); assert.equal(report.issue, requiredIssue, `${label}.issue`); assert.match(report.taskId, /^[a-z][a-z0-9-]*$/, `${label}.taskId`); assert.match(report.commitId, /^[a-f0-9]{7,40}$/, `${label}.commitId`); assert.match(report.acceptanceLevel, /^[a-z][a-z0-9_-]*$/, `${label}.acceptanceLevel`); assert.equal(report.devOnly, true, `${label}.devOnly`); assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); assertObject(report.sourceContract, `${label}.sourceContract`); assertStatus(report.sourceContract.status, `${label}.sourceContract.status`); assertString(report.sourceContract.summary, `${label}.sourceContract.summary`); assertStringArray(report.sourceContract.documents, `${label}.sourceContract.documents`, { minLength: requiredDocs.length }); const documents = new Set(report.sourceContract.documents); assertUnique(report.sourceContract.documents, `${label}.sourceContract.documents`); for (const requiredDoc of requiredDocs) { assert.ok(documents.has(requiredDoc), `${label}.sourceContract.documents missing ${requiredDoc}`); } for (const [index, documentPath] of report.sourceContract.documents.entries()) { const absolutePath = assertRepoRelativePath( documentPath, `${label}.sourceContract.documents[${index}]` ); await access(absolutePath, fsConstants.R_OK); } assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 }); assertUnique(report.validationCommands, `${label}.validationCommands`); for (const requiredCommand of requiredValidationCommands) { assert.ok( report.validationCommands.includes(requiredCommand), `${label}.validationCommands missing ${requiredCommand}` ); } assertObject(report.localSmoke, `${label}.localSmoke`); assertStatus(report.localSmoke.status, `${label}.localSmoke.status`); assertStringArray(report.localSmoke.commands, `${label}.localSmoke.commands`, { minLength: 1 }); assertUnique(report.localSmoke.commands, `${label}.localSmoke.commands`); assert.ok( report.localSmoke.commands.includes(requiredSmokeCommand), `${label}.localSmoke.commands missing ${requiredSmokeCommand}` ); assertStringArray(report.localSmoke.evidence, `${label}.localSmoke.evidence`, { minLength: 1 }); assertString(report.localSmoke.summary, `${label}.localSmoke.summary`); assertObject(report.dryRun, `${label}.dryRun`); assertStatus(report.dryRun.status, `${label}.dryRun.status`); assertStringArray(report.dryRun.commands, `${label}.dryRun.commands`, { minLength: 1 }); assertUnique(report.dryRun.commands, `${label}.dryRun.commands`); assert.ok( report.dryRun.commands.includes(requiredDryRunCommand), `${label}.dryRun.commands missing ${requiredDryRunCommand}` ); assertStringArray(report.dryRun.evidence, `${label}.dryRun.evidence`, { minLength: 1 }); assertString(report.dryRun.summary, `${label}.dryRun.summary`); assertObject(report.devPreconditions, `${label}.devPreconditions`); assertStatus(report.devPreconditions.status, `${label}.devPreconditions.status`); assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, { minLength: 1 }); assertString(report.devPreconditions.summary, `${label}.devPreconditions.summary`); assertArray(report.blockers, `${label}.blockers`); assertUnique( report.blockers.map((blocker) => `${blocker.type}::${blocker.scope}`), `${label}.blockers` ); for (const [index, blocker] of report.blockers.entries()) { const blockerLabel = `${label}.blockers[${index}]`; assertObject(blocker, blockerLabel); for (const field of ["type", "scope", "status", "summary"]) { assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`); } assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be a known blocker type`); assertString(blocker.scope, `${blockerLabel}.scope`); assert.ok(blockerStates.has(blocker.status), `${blockerLabel}.status must be open, acknowledged, or closed`); assertString(blocker.summary, `${blockerLabel}.summary`); } if (Object.hasOwn(report, "notes")) { assertString(report.notes, `${label}.notes`); } } async function main() { const reportFiles = await collectReportFiles(); assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file"); for (const reportFile of reportFiles) { await validateReport(reportFile); } console.log(`validated ${reportFiles.length} dev-gate report JSON file(s)`); } await main();