docs: add dev gate report contract

This commit is contained in:
HWLAB Code Queue
2026-05-21 15:35:09 +00:00
parent 2ae6e15df3
commit e327a90e6c
3 changed files with 357 additions and 0 deletions
+220
View File
@@ -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();