docs: add dev gate report contract
This commit is contained in:
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user