528 lines
22 KiB
JavaScript
528 lines
22 KiB
JavaScript
#!/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 allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33"]);
|
|
const requiredPreflightIssue = "pikasTech/HWLAB#34";
|
|
const requiredValidationCommands = [
|
|
"node --check scripts/validate-dev-gate-report.mjs",
|
|
"node scripts/validate-dev-gate-report.mjs"
|
|
];
|
|
const requiredPreflightValidationCommands = [
|
|
"node --check scripts/dev-gate-preflight.mjs",
|
|
"node --check scripts/src/dev-gate-preflight.mjs",
|
|
"node scripts/dev-gate-preflight.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 requiredDevM3Issue = "pikasTech/HWLAB#38";
|
|
const requiredDevM3ValidationCommands = [
|
|
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
|
|
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
|
|
];
|
|
const requiredDevM3Docs = [
|
|
"docs/dev-acceptance-matrix.md",
|
|
"docs/m3-hardware-loop.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"]);
|
|
const preflightConclusions = new Set(["ready", "blocked"]);
|
|
const requiredPreflightSupports = [
|
|
"pikasTech/HWLAB#7",
|
|
"pikasTech/HWLAB#12",
|
|
"pikasTech/HWLAB#22",
|
|
"pikasTech/HWLAB#23",
|
|
"pikasTech/HWLAB#29",
|
|
"pikasTech/HWLAB#30",
|
|
"pikasTech/HWLAB#31"
|
|
];
|
|
|
|
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 assertTimestamp(value, label) {
|
|
assertString(value, label);
|
|
assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be an RFC 3339 timestamp`);
|
|
}
|
|
|
|
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);
|
|
if (report.reportKind === "dev-gate-preflight") {
|
|
await validatePreflightReport(relativePath, report);
|
|
return;
|
|
}
|
|
|
|
const label = relativePath;
|
|
|
|
assertObject(report, label);
|
|
if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") {
|
|
await validateDevM3Report(report, label);
|
|
return;
|
|
}
|
|
|
|
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.ok(allowedIssues.has(report.issue), `${label}.issue must be a known DEV gate 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 validateDevM3Report(report, label) {
|
|
for (const field of [
|
|
"$schema",
|
|
"$id",
|
|
"reportVersion",
|
|
"issue",
|
|
"taskId",
|
|
"commitId",
|
|
"acceptanceLevel",
|
|
"devOnly",
|
|
"prodDisabled",
|
|
"sourceContract",
|
|
"validationCommands",
|
|
"runtimeTarget",
|
|
"safetyGates",
|
|
"liveChecks",
|
|
"liveOperation",
|
|
"blockers",
|
|
"summary"
|
|
]) {
|
|
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, requiredDevM3Issue, `${label}.issue`);
|
|
assert.equal(report.taskId, "dev-m3-hardware-loop", `${label}.taskId`);
|
|
assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`);
|
|
assert.equal(report.acceptanceLevel, "dev_m3_hardware_loop", `${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`);
|
|
assertStringArray(report.sourceContract.documents, `${label}.sourceContract.documents`, {
|
|
minLength: requiredDevM3Docs.length
|
|
});
|
|
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
|
|
const documents = new Set(report.sourceContract.documents);
|
|
assertUnique(report.sourceContract.documents, `${label}.sourceContract.documents`);
|
|
for (const requiredDoc of requiredDevM3Docs) {
|
|
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 requiredDevM3ValidationCommands) {
|
|
assert.ok(
|
|
report.validationCommands.includes(requiredCommand),
|
|
`${label}.validationCommands missing ${requiredCommand}`
|
|
);
|
|
}
|
|
|
|
assertObject(report.runtimeTarget, `${label}.runtimeTarget`);
|
|
assert.equal(report.runtimeTarget.endpoint, "http://74.48.78.17:6667", `${label}.runtimeTarget.endpoint`);
|
|
assert.equal(report.runtimeTarget.namespace, "hwlab-dev", `${label}.runtimeTarget.namespace`);
|
|
assert.equal(report.runtimeTarget.environment, "dev", `${label}.runtimeTarget.environment`);
|
|
assert.equal(report.runtimeTarget.requiredBoxSimulators, 2, `${label}.runtimeTarget.requiredBoxSimulators`);
|
|
assert.equal(report.runtimeTarget.requiredGatewaySimulators, 2, `${label}.runtimeTarget.requiredGatewaySimulators`);
|
|
assert.equal(report.runtimeTarget.realHardwareAllowed, false, `${label}.runtimeTarget.realHardwareAllowed`);
|
|
assert.equal(report.runtimeTarget.prodAllowed, false, `${label}.runtimeTarget.prodAllowed`);
|
|
|
|
assertObject(report.safetyGates, `${label}.safetyGates`);
|
|
for (const field of [
|
|
"liveFlagRequired",
|
|
"confirmDevRequired",
|
|
"confirmedNonProductionRequired",
|
|
"prodForbidden",
|
|
"realHardwareForbidden",
|
|
"secretReadForbidden",
|
|
"forcePushForbidden",
|
|
"unideskRuntimeSubstitutionForbidden"
|
|
]) {
|
|
assert.equal(report.safetyGates[field], true, `${label}.safetyGates.${field}`);
|
|
}
|
|
|
|
assertArray(report.liveChecks, `${label}.liveChecks`);
|
|
assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`);
|
|
assertUnique(report.liveChecks.map((check) => check.id), `${label}.liveChecks`);
|
|
for (const [index, check] of report.liveChecks.entries()) {
|
|
const checkLabel = `${label}.liveChecks[${index}]`;
|
|
assertObject(check, checkLabel);
|
|
for (const field of ["id", "status", "summary", "evidence"]) {
|
|
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
|
|
}
|
|
assertString(check.id, `${checkLabel}.id`);
|
|
assertStatus(check.status, `${checkLabel}.status`);
|
|
assertString(check.summary, `${checkLabel}.summary`);
|
|
assertStringArray(check.evidence, `${checkLabel}.evidence`, { minLength: 1 });
|
|
if (Object.hasOwn(check, "blockerClass")) {
|
|
assert.ok(blockerTypes.has(check.blockerClass), `${checkLabel}.blockerClass must be known`);
|
|
}
|
|
}
|
|
|
|
if (Object.hasOwn(report, "localRuntimeObservations")) {
|
|
assertArray(report.localRuntimeObservations, `${label}.localRuntimeObservations`);
|
|
for (const [index, observation] of report.localRuntimeObservations.entries()) {
|
|
const observationLabel = `${label}.localRuntimeObservations[${index}]`;
|
|
assertObject(observation, observationLabel);
|
|
for (const field of ["id", "status", "command", "summary"]) {
|
|
assert.ok(Object.hasOwn(observation, field), `${observationLabel} missing ${field}`);
|
|
assertString(observation[field], `${observationLabel}.${field}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.hasOwn(report, "readOnlySupplementalEvidence")) {
|
|
assertArray(report.readOnlySupplementalEvidence, `${label}.readOnlySupplementalEvidence`);
|
|
for (const [index, evidence] of report.readOnlySupplementalEvidence.entries()) {
|
|
const evidenceLabel = `${label}.readOnlySupplementalEvidence[${index}]`;
|
|
assertObject(evidence, evidenceLabel);
|
|
for (const field of ["id", "status", "source", "summary", "evidence", "requiredFollowUp"]) {
|
|
assert.ok(Object.hasOwn(evidence, field), `${evidenceLabel} missing ${field}`);
|
|
}
|
|
assertString(evidence.id, `${evidenceLabel}.id`);
|
|
assert.ok(["pass", "gap"].includes(evidence.status), `${evidenceLabel}.status must be pass or gap`);
|
|
assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`);
|
|
assertString(evidence.summary, `${evidenceLabel}.summary`);
|
|
assertObject(evidence.evidence, `${evidenceLabel}.evidence`);
|
|
assertString(evidence.requiredFollowUp, `${evidenceLabel}.requiredFollowUp`);
|
|
if (Object.hasOwn(evidence, "blockerClass") && evidence.blockerClass !== null) {
|
|
assert.ok(blockerTypes.has(evidence.blockerClass), `${evidenceLabel}.blockerClass must be known`);
|
|
}
|
|
}
|
|
}
|
|
|
|
assertObject(report.liveOperation, `${label}.liveOperation`);
|
|
assertStatus(report.liveOperation.status, `${label}.liveOperation.status`);
|
|
for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) {
|
|
assertString(report.liveOperation[field], `${label}.liveOperation.${field}`);
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
assertObject(report.summary, `${label}.summary`);
|
|
assertStatus(report.summary.status, `${label}.summary.status`);
|
|
assertTimestamp(report.summary.observedAt, `${label}.summary.observedAt`);
|
|
assertString(report.summary.result, `${label}.summary.result`);
|
|
}
|
|
|
|
async function validatePreflightReport(relativePath, report) {
|
|
const label = relativePath;
|
|
|
|
assertObject(report, label);
|
|
for (const field of [
|
|
"$schema",
|
|
"$id",
|
|
"reportVersion",
|
|
"reportKind",
|
|
"issue",
|
|
"supports",
|
|
"target",
|
|
"generatedAt",
|
|
"mode",
|
|
"devOnly",
|
|
"prodDisabled",
|
|
"forbiddenActions",
|
|
"validationCommands",
|
|
"conclusion",
|
|
"checks",
|
|
"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.reportKind, "dev-gate-preflight", `${label}.reportKind`);
|
|
assert.equal(report.issue, requiredPreflightIssue, `${label}.issue`);
|
|
assert.equal(report.mode, "read-only", `${label}.mode`);
|
|
assert.equal(report.devOnly, true, `${label}.devOnly`);
|
|
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
|
|
assert.ok(preflightConclusions.has(report.conclusion), `${label}.conclusion must be ready or blocked`);
|
|
assert.match(report.generatedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, `${label}.generatedAt`);
|
|
|
|
assertStringArray(report.supports, `${label}.supports`, { minLength: requiredPreflightSupports.length });
|
|
for (const supportedIssue of requiredPreflightSupports) {
|
|
assert.ok(report.supports.includes(supportedIssue), `${label}.supports missing ${supportedIssue}`);
|
|
}
|
|
|
|
assertObject(report.target, `${label}.target`);
|
|
assert.equal(report.target.ref, "origin/main", `${label}.target.ref`);
|
|
assert.match(report.target.commitId, /^[a-f0-9]{40}$/, `${label}.target.commitId`);
|
|
assert.equal(report.target.shortCommitId, report.target.commitId.slice(0, 7), `${label}.target.shortCommitId`);
|
|
|
|
assertStringArray(report.forbiddenActions, `${label}.forbiddenActions`, { minLength: 1 });
|
|
for (const forbiddenAction of ["prod-deploy", "secret-material-read", "unidesk-runtime-substitute", "force-push"]) {
|
|
assert.ok(report.forbiddenActions.includes(forbiddenAction), `${label}.forbiddenActions missing ${forbiddenAction}`);
|
|
}
|
|
|
|
assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 });
|
|
for (const requiredCommand of requiredPreflightValidationCommands) {
|
|
assert.ok(
|
|
report.validationCommands.includes(requiredCommand),
|
|
`${label}.validationCommands missing ${requiredCommand}`
|
|
);
|
|
}
|
|
|
|
assertArray(report.checks, `${label}.checks`);
|
|
assert.ok(report.checks.length >= 1, `${label}.checks must not be empty`);
|
|
assertUnique(report.checks.map((check) => check.id), `${label}.checks`);
|
|
for (const [index, check] of report.checks.entries()) {
|
|
const checkLabel = `${label}.checks[${index}]`;
|
|
assertObject(check, checkLabel);
|
|
for (const field of ["id", "category", "status", "summary", "evidence"]) {
|
|
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
|
|
}
|
|
assert.match(check.id, /^[a-z][a-z0-9-]*$/, `${checkLabel}.id`);
|
|
assertString(check.category, `${checkLabel}.category`);
|
|
assertStatus(check.status, `${checkLabel}.status`);
|
|
assertString(check.summary, `${checkLabel}.summary`);
|
|
assertArray(check.evidence, `${checkLabel}.evidence`);
|
|
}
|
|
|
|
assertArray(report.blockers, `${label}.blockers`);
|
|
if (report.conclusion === "blocked") {
|
|
assert.ok(report.blockers.length >= 1, `${label}.blockers required when conclusion is blocked`);
|
|
}
|
|
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", "nextTask"]) {
|
|
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`);
|
|
assertString(blocker.nextTask, `${blockerLabel}.nextTask`);
|
|
}
|
|
|
|
if (report.conclusion === "ready") {
|
|
assert.equal(report.blockers.length, 0, `${label}.blockers must be empty when ready`);
|
|
}
|
|
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();
|