diff --git a/docs/dev-gate-report.md b/docs/dev-gate-report.md new file mode 100644 index 00000000..39b361d3 --- /dev/null +++ b/docs/dev-gate-report.md @@ -0,0 +1,64 @@ +# HWLAB DEV Gate Report Contract + +This document defines the canonical DEV gate report format for +`pikasTech/HWLAB#31`. It is the handoff surface for the parallel workstreams +behind `#7/#9/#12/#17/#20/#21/#22/#23/#24/#25/#26`, so source-contract +evidence, local smoke, dry-run notes, real DEV prerequisites, and remaining +blockers stay in one place. + +This contract is report-only. It does not authorize a real DEV or PROD +deployment. + +## Files + +- `reports/dev-gate/dev-gate-report.example.json`: machine-readable example. +- `scripts/validate-dev-gate-report.mjs`: local validator for the report shape. + +## Required Fields + +Every report must include: + +- `$schema` +- `$id` +- `reportVersion` +- `issue` +- `taskId` +- `commitId` +- `acceptanceLevel` +- `devOnly` +- `prodDisabled` +- `sourceContract` +- `validationCommands` +- `localSmoke` +- `dryRun` +- `devPreconditions` +- `blockers` + +## Field Rules + +- `issue` is fixed to `pikasTech/HWLAB#31`. +- `devOnly` must be `true`. +- `prodDisabled` must be `true`. +- `commitId` must be a git SHA string. +- `taskId` and `acceptanceLevel` must be stable slugs, not free-form prose. +- `validationCommands` must record the exact commands used to validate the + report contract. +- `sourceContract.documents` should point at the frozen docs that define the + gate contract. +- `localSmoke`, `dryRun`, and `devPreconditions` should each summarize their + own state with a status, command list, evidence list, and short summary. +- `blockers` should list the remaining open blockers as machine-readable + objects. + +## Validation + +Run: + +```sh +node --check scripts/validate-dev-gate-report.mjs +node scripts/validate-dev-gate-report.mjs +``` + +The validator scans `reports/dev-gate/*.json` by default. It only checks the +contract shape and the frozen report metadata; it does not run any DEV or PROD +deployment. diff --git a/reports/dev-gate/dev-gate-report.example.json b/reports/dev-gate/dev-gate-report.example.json new file mode 100644 index 00000000..20bf9274 --- /dev/null +++ b/reports/dev-gate/dev-gate-report.example.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + "$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-gate-report.example.json", + "reportVersion": "v1", + "issue": "pikasTech/HWLAB#31", + "taskId": "dev-gate-report-contract", + "commitId": "5583e4b", + "acceptanceLevel": "dev_gate", + "devOnly": true, + "prodDisabled": true, + "sourceContract": { + "status": "pass", + "documents": [ + "docs/dev-acceptance-matrix.md", + "docs/m0-contract-audit.md" + ], + "summary": "The gate report contract is anchored to the frozen DEV acceptance matrix and the M0 audit." + }, + "validationCommands": [ + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs" + ], + "localSmoke": { + "status": "blocked", + "commands": [ + "node scripts/m1-contract-smoke.mjs" + ], + "evidence": [ + "No local smoke evidence is attached to this example contract." + ], + "summary": "The example contract does not bundle a local smoke result." + }, + "dryRun": { + "status": "not_run", + "commands": [ + "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run" + ], + "evidence": [ + "No dry-run output is attached to this example contract." + ], + "summary": "The example contract does not bundle a dry-run result." + }, + "devPreconditions": { + "status": "blocked", + "requirements": [ + "DEV route remains frozen at http://74.48.78.17:6667", + "Runtime evidence names frozen HWLAB service IDs", + "No PROD path, secret read, or real deployment is attempted" + ], + "summary": "A live DEV observation is still required before a real gate can be claimed." + }, + "blockers": [ + { + "type": "environment_blocker", + "scope": "localSmoke", + "status": "open", + "summary": "Example contract only; no local smoke run is attached." + }, + { + "type": "runtime_blocker", + "scope": "dryRun", + "status": "open", + "summary": "No dry-run output is attached." + }, + { + "type": "network_blocker", + "scope": "devPreconditions", + "status": "open", + "summary": "No live DEV precondition evidence is attached." + } + ], + "notes": "Contract-only example. Do not treat this file as a real DEV gate report." +} diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs new file mode 100644 index 00000000..a45195da --- /dev/null +++ b/scripts/validate-dev-gate-report.mjs @@ -0,0 +1,220 @@ +#!/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();