feat: add M5 DEV gate aggregator v2

This commit is contained in:
HWLAB Code Queue
2026-05-21 18:28:19 +00:00
parent 1e9591a661
commit 35ecb5e038
8 changed files with 2538 additions and 287 deletions
+39 -17
View File
@@ -1,8 +1,9 @@
# DEV Evidence / Blocker Aggregator
This is the lightweight report-only entry point for `pikasTech/HWLAB#41`. It
helps a commander decide whether the DEV acceptance path is green, blocked, or
ready for another repair round across `#33-#39`, `#7`, and `#31`.
This is the lightweight report-only entry point for `pikasTech/HWLAB#41` and
the M5 DEV gate aggregator v2 for `pikasTech/HWLAB#58`. It helps a commander
decide whether the DEV acceptance path is green, blocked, or ready for another
repair round across `#33-#39`, `#46`, `#7`, and the MVP DoD in `#9`.
Run from the repository root:
@@ -16,6 +17,17 @@ For a short validation response:
node scripts/dev-evidence-blocker-aggregator.mjs --check
```
To refresh the committed v2 machine and human reports:
```sh
node scripts/dev-evidence-blocker-aggregator.mjs --write-report
```
The generated files are:
- `reports/dev-gate/dev-m5-gate-aggregator-v2.json`
- `reports/dev-gate/dev-m5-gate-aggregator-v2.md`
## Source Levels
The aggregator keeps evidence levels separate so fixture output cannot be
@@ -42,27 +54,37 @@ promoted to live DEV evidence:
## Blocker Posture
If GitHub issues are not readable in the runner, the report marks
`issue-read` as blocked and uses only committed repository artifacts plus the
task prompt. This keeps #33-#39 status visible without treating issue comments
as silently available.
The v2 report reads committed report fixtures only:
- `reports/dev-gate/dev-preflight-report.json`
- `reports/dev-gate/dev-deploy-report.json`
- `reports/dev-gate/dev-artifacts.json`
- `reports/dev-gate/dev-edge-health.json`
- `reports/dev-gate/dev-m3-hardware-loop.json`
- `reports/dev-gate/dev-m4-agent-loop.json`
- `reports/dev-gate/dev-mvp-gate-report.json`
- `reports/d601-k3s-readonly-observability.json`
The report is blocked until real DEV-LIVE evidence is attached for the DEV
route and downstream HWLAB runtime services. Current source, local, and dry-run
contracts are useful readiness signals, but they are not live DEV proof.
## Next-Round Buckets
## Next-Round Order
The JSON output groups recommended next actions into:
The JSON output groups recommended next actions in dependency order:
- `artifact/base image`: real publish or provenance evidence for frozen HWLAB
service IDs.
- `edge/network`: read-only DEV route observations for ingress, edge, frp, and
D601 router.
- `runtime/health`: DEV health JSON for cloud, gateway/simulator,
patch-panel, agent, worker, skills, and CLI surfaces.
- `M3/M4 live`: narrow authorized DEV-only loop evidence with trace, audit,
operation, session, evidence, and cleanup IDs.
1. `base-image`: preload or tag an approved Node 20 DEV builder image, then
rerun the base-image and artifact publish preflights.
2. `artifact-publish`, `artifact-catalog`, `ghcr`: publish every frozen HWLAB
service artifact and record immutable registry digests for the selected
`origin/main` commit.
3. `d601-k3s`, `kubectl`, kubeconfig/maintenance bridge: restore read-only
hwlab-dev cluster observability without printing Secrets.
4. `cloud-api-db`: prove DEV cloud-api DB env and health readiness with
redacted values only.
5. `dev-edge`, `dev-edge-health`, `dev-ingress-health`: repair
`:6667`/frp/edge/router reachability, then rerun read-only DEV edge health
before M3/M4/M5 live loops.
## Boundaries
+28
View File
@@ -64,6 +64,34 @@ but it must include:
- evidence, audit, trace, operation, agent session, worker session, gateway
session, and project IDs when the phase touches an MVP loop.
## DEV Gate Aggregator v2
Use `reports/dev-gate/dev-m5-gate-aggregator-v2.json` and
`reports/dev-gate/dev-m5-gate-aggregator-v2.md` as the current report-only M5
gate summary for `pikasTech/HWLAB#58`. Refresh them only from committed
fixtures:
```sh
node scripts/dev-evidence-blocker-aggregator.mjs --write-report
node scripts/validate-dev-gate-report.mjs
```
The aggregator separates `SOURCE`, `LOCAL`, `DRY-RUN`, `DEV-LIVE`, and
`BLOCKED`. It must never promote source, local, dry-run, contract-only,
not-run, or failed-live evidence into `DEV-LIVE`.
Current unblock order:
1. Provide an approved Node 20 DEV builder base image for artifact publish.
2. Publish every frozen HWLAB service artifact and record immutable registry
digests for the selected `origin/main` commit.
3. Restore read-only D601 `hwlab-dev` k3s observability with `kubectl` or an
approved kubeconfig/maintenance bridge, without reading Secrets.
4. Prove DEV `hwlab-cloud-api` DB env and health readiness with redacted values
only.
5. Repair the `:6667`/`frp`/edge/router path and rerun read-only DEV edge
health before any M3, M4, or M5 live loop.
## Phase Gates
Run phases in order. A later phase may start only after every earlier required
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"validate": "node scripts/validate-contract.mjs",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
"l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
# HWLAB M5 DEV Gate Aggregator v2
Status: blocked
Generated from: `1e9591a6611e`
Scope: DEV only, report-only
## Summary
SOURCE, LOCAL, and DRY-RUN evidence exists, but DEV-LIVE acceptance is blocked by artifact, observability, DB, edge/frp, and loop evidence gaps.
## Milestones
| Milestone | Status | Highest visible level | Live evidence | Summary |
| --- | --- | --- | --- | --- |
| M0 | pass | SOURCE | missing_or_blocked | contract source is available; highest visible level is SOURCE; status is pass. |
| M1 | pass | LOCAL | missing_or_blocked | local smoke is available; highest visible level is LOCAL; status is pass. |
| M2 | blocked | DRY-RUN | missing_or_blocked | deploy/runtime readiness is blocked before live DEV; highest visible level is DRY-RUN; status is blocked. |
| M3 | blocked | LOCAL | missing_or_blocked | hardware loop has source/local shape but no live operation; highest visible level is LOCAL; status is blocked. |
| M4 | blocked | LOCAL | missing_or_blocked | agent loop has local smoke but live preflight is blocked; highest visible level is LOCAL; status is blocked. |
| M5 | blocked | DRY-RUN | missing_or_blocked | dry-run is green but live MVP gate is blocked; highest visible level is DRY-RUN; status is blocked. |
## #9 DoD Checks
| Check | Status | Evidence level | Summary |
| --- | --- | --- | --- |
| m0-source-contract | pass | SOURCE | M0 contract checks are source-level evidence only. |
| m1-local-smoke | pass | LOCAL | M1 local smoke is not a live DEV substitute. |
| artifact-publish-digests | blocked | BLOCKED | artifactState=contract-skeleton, ciPublished=false, registryVerified=false, sha256=0, not_published=13 |
| d601-k3s-observability | blocked | BLOCKED | D601 runner lacks kubectl/k3s/kubeconfig observability for hwlab-dev. |
| dev-edge-frp-6667 | blocked | BLOCKED | No committed report proves live HTTP 200/JSON on http://74.48.78.17:6667. |
| cloud-api-db-ready | blocked | BLOCKED | Manifest-level DB env exists, but live DB health readiness is still blocked/missing. |
| m5-mvp-dev-live | blocked | BLOCKED | M5 dry-run passed; bounded DEV-LIVE MVP e2e has not passed. |
## Blockers
| Priority | Order | Type | Scope | Summary |
| --- | ---: | --- | --- | --- |
| P0 | 1 | environment_blocker | base-image | HWLAB_DEV_BASE_IMAGE is not set and no approved local DEV builder base image was found in the Docker image cache; expected node:20-bookworm-slim or 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim. |
| P1 | 3 | observability_blocker | artifact-publish | DEV artifact catalog has no CI publish, registry verification, or registry digests |
| P1 | 3 | observability_blocker | artifact-source-commit | deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 9b47ebe49f70 |
| P1 | 3 | runtime_blocker | artifact-catalog | deploy/artifact-catalog.dev.json does not prove published artifacts for origin/main 1a2efd4; ciPublished=false, registryVerified=false, not_published=13. |
| P1 | 3 | runtime_blocker | dev-artifact-publish | reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main 1a2efd4; current status is blocked with 0/13 published. |
| P1 | 3 | runtime_blocker | ghcr | This preflight could not verify GHCR manifests for the DEV catalog images without reading credentials. |
| P1 | 4 | environment_blocker | d601-k3s-client-binary | Neither kubectl nor k3s is installed in this runner PATH. |
| P1 | 4 | environment_blocker | d601-kubeconfig-path | No readable KUBECONFIG/default k3s kubeconfig path was found by metadata checks. |
| P1 | 4 | environment_blocker | d601-maintenance-ssh-bridge | No SSH maintenance bridge was detected from ssh binary plus known environment variables or ~/.ssh/config metadata. |
| P1 | 4 | environment_blocker | kubectl | kubectl is not installed in the runner |
| P1 | 4 | environment_blocker | d601-k3s | D601 runner lacks kubectl and no default kubeconfig was used by this preflight. |
| P1 | 5 | runtime_blocker | cloud-api-db | cloud-api health reports DB config blocked; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE |
| P1 | 5 | runtime_blocker | cloud-api-db-health-gate | cloud-api DB runtime env is not ready; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE. |
| P2 | 6 | network_blocker | dev-edge | http://74.48.78.17:6667/health/live is not reachable from this runner. |
| P2 | 6 | network_blocker | dev-edge-health | live network probes require --live |
| P2 | 6 | network_blocker | dev-ingress-health | Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached. |
| P2 | 6 | network_blocker | devPreconditions | connect ECONNREFUSED 74.48.78.17:6667 |
| P2 | 6 | network_blocker | devPreconditions | http://74.48.78.17:6667/health and /live both fail with curl exit 7; live DEV ingress evidence is missing. |
## Next Unblock Order
1. Preload node:20-bookworm-slim into the D601 Docker cache, or tag it as 127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim. Set HWLAB_DEV_BASE_IMAGE=node:20-bookworm-slim or HWLAB_DEV_BASE_IMAGE=127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim when invoking artifact publish. Rerun this preflight and require status=ready before #35 artifact publish. Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes.
Evidence required: Base-image preflight status=ready with approved Node 20 builder base and no UniDesk/runtime substitute.
2. Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence.
Evidence required: Artifact publish report with ciPublished=true, registryVerified=true, and sha256 digest for each frozen service ID.
3. Install kubectl in the D601 Code Queue runner image, or mount an approved k3s kubectl client path for read-only hwlab-dev observation.
Evidence required: Read-only kubectl/k3s report proving pods/services/configmaps are observable in hwlab-dev without reading Secrets.
4. Configure and verify the DEV cloud-api DB env readiness through health output, without reading secret values.
Evidence required: Cloud API health/live output showing DB env ready and redacted secret references, without secret material.
5. Bring up or repair the D601-to-master frp route and hwlab-edge-proxy, then rerun the health probe.
Evidence required: Read-only DEV route observation for :6667/frp/edge/router with HWLAB service identity and artifact identity.
## Validation
- `node --check scripts/dev-evidence-blocker-aggregator.mjs`
- `node --check scripts/src/dev-evidence-blocker-aggregator.mjs`
- `node scripts/dev-evidence-blocker-aggregator.mjs --check`
- `node scripts/dev-evidence-blocker-aggregator.mjs --markdown`
- `node --check scripts/validate-dev-gate-report.mjs`
- `node scripts/validate-dev-gate-report.mjs`
## Boundary
This report reads committed reports and fixtures only. It does not deploy, call DEV, call PROD, read secrets, restart runtime, run heavy e2e, or substitute UniDesk runtime for HWLAB runtime.
+33 -268
View File
@@ -1,281 +1,46 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { access, readdir, readFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
import {
buildReport,
formatCheckSummary,
renderMarkdown,
repoRoot
} from "./src/dev-evidence-blocker-aggregator.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const levels = ["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCKED"];
const blockerTypes = ["contract_blocker", "environment_blocker", "network_blocker", "runtime_blocker", "agent_blocker", "observability_blocker", "safety_blocker"];
const requiredArtifactFields = ["serviceId", "commitId", "image", "tag", "digest", "buildSource", "deployEnv", "healthTimestamp"];
const issue = (id) => `pikasTech/HWLAB${id}`;
const allSupportedIssues = ["#33", "#34", "#35", "#36", "#37", "#38", "#39", "#7", "#31"].map(issue);
const contracts = [
contract("M0", "SOURCE", "artifact/base image", ["#7", "#31"], ["docs/m0-contract-audit.md", "protocol/schemas/evidence-record.schema.json", "protocol/evidence-chain.md", "protocol/examples/m0-contract/service-ids.json", "scripts/validate-m0-contract.mjs", "scripts/validate-evidence-chain.mjs"], ["node scripts/validate-m0-contract.mjs", "node scripts/validate-evidence-chain.mjs"], "Source contracts freeze service IDs, JSON-RPC, audit, topology, evidence records, and deploy shape."),
contract("M1", "LOCAL", "runtime/health", ["#7", "#31"], ["docs/m1-local-smoke.md", "fixtures/mvp/runtime.json", "scripts/m1-contract-smoke.mjs"], ["node scripts/m1-contract-smoke.mjs"], "Local smoke covers cloud-api, simulators, patch-panel routing, CLI dry-run boundary, and no DEV/PROD mutation."),
contract("M2", "DRY-RUN", "edge/network", ["#7", "#31", "#33", "#34", "#35", "#39"], ["docs/m2-dev-deploy-smoke.md", "docs/dev-acceptance-checklist.json", "fixtures/dev-deploy-smoke/dev-deploy-smoke.json", "deploy/artifact-catalog.dev.json", "scripts/m2-dev-deploy-smoke.mjs", "scripts/validate-artifact-catalog.mjs", "scripts/validate-runtime-boundary.mjs"], ["node scripts/m2-dev-deploy-smoke.mjs --dry-run", "node scripts/validate-artifact-catalog.mjs", "node scripts/validate-runtime-boundary.mjs"], "Dry-run contract covers edge proxy, frp, D601 router, cloud surface, agent runtime, sim, and patch-panel route phases."),
contract("M3", "LOCAL", "M3/M4 live", ["#7", "#31", "#36", "#39"], ["docs/m3-hardware-loop.md", "fixtures/mvp/m3-hardware-loop/topology.json", "scripts/m3-hardware-loop-smoke.mjs"], ["node scripts/m3-hardware-loop-smoke.mjs"], "Local hardware trusted loop fixture proves patch-panel-owned box-simu-1 DO1 to box-simu-2 DI1 propagation."),
contract("M4", "LOCAL", "M3/M4 live", ["#7", "#31", "#37", "#39"], ["docs/m4-agent-loop.md", "fixtures/mvp/m4-agent-loop/runtime.json", "fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md", "scripts/m4-agent-loop-smoke.mjs"], ["node scripts/m4-agent-loop-smoke.mjs"], "Local agent loop fixture covers session lifecycle, workspace isolation, skills commit wiring, evidence, and cleanup."),
contract("M5", "DRY-RUN", "runtime/health", ["#7", "#31", "#38", "#39"], ["docs/m5-mvp-e2e.md", "protocol/mvp-e2e-contract.md", "fixtures/mvp/m5-e2e/dry-run-plan.json", "scripts/m5-mvp-e2e-dry-run.mjs"], ["node scripts/m5-mvp-e2e-dry-run.mjs"], "Dry-run orchestration checks the M0-M4 dependency graph, MVP sequence, health, trace, audit, evidence, and cleanup contracts.")
];
const visibleParallelResults = [
parallel("#33", "BLOCKED", "Prompt says #33-#39 are parallel real DEV workstreams. No issue-specific committed DEV-LIVE result is visible in this checkout."),
parallel("#34", "DRY-RUN", "Repository M2 dry-run and artifact contracts can support deploy/artifact review, but this runner cannot read the issue result itself."),
parallel("#35", "DRY-RUN", "Repository DEV route fixtures cover edge, frp, and D601 router contracts as DRY-RUN evidence only."),
parallel("#36", "LOCAL", "Repository M3 topology fixture and smoke script provide LOCAL evidence only; no live DEV M3 loop output is attached."),
parallel("#37", "LOCAL", "Repository M4 runtime fixture and smoke script provide LOCAL evidence only; no live DEV M4 agent run output is attached."),
parallel("#38", "DRY-RUN", "Repository M5 dry-run plan validates orchestration readiness, not a live DEV MVP e2e run."),
parallel("#39", "BLOCKED", "No repository-local DEV-LIVE gate result is visible to this aggregator.")
];
const nextRoundRecommendations = [
next("artifact/base image", "high", ["#33", "#35"], "Publish or attach real DEV artifact provenance for each frozen service ID: commitId, image, immutable tag, digest, buildSource, deployEnv, and healthTimestamp."),
next("edge/network", "high", ["#35", "#39"], "Attach read-only DEV route observations for public ingress, master edge proxy, frp, and D601 hwlab-dev/hwlab-router without deploying, restarting, or reading secrets."),
next("runtime/health", "high", ["#34", "#35", "#38", "#39"], "Attach current DEV health JSON for cloud API/web, gateway or simulator, box simulator, patch panel, agent manager, worker, skills, and CLI, all tied to HWLAB service IDs."),
next("M3/M4 live", "medium", ["#36", "#37", "#38"], "After DEV runtime smoke is green, run the smallest authorized DEV-only M3/M4 loop and attach trace, audit, evidence, operation, session, and cleanup IDs.")
];
function contract(milestone, source, category, supports, paths, commands, summary) {
return {
milestone,
source,
status: source === "SOURCE" ? "contract_available" : `${source.toLowerCase().replace("-", "_")}_contract_available`,
category,
supports: supports.map(issue),
paths,
commands,
summary
};
}
function parallel(issue, source, visibleResult) {
return {
issue: `pikasTech/HWLAB${issue}`,
issueRead: "blocked",
visibleResult,
source
};
}
function next(category, priority, owners, recommendation) {
return { category, priority, owners, recommendation };
}
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
}
async function pathExists(relativePath) {
try {
await access(path.join(repoRoot, relativePath), fsConstants.R_OK);
return true;
} catch {
return false;
}
}
function gitCommit() {
try {
return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
} catch {
return "unknown";
}
}
async function assertStaticContracts() {
const checklist = await readJSON("docs/dev-acceptance-checklist.json");
assert.equal(checklist.environment, ENVIRONMENT_DEV, "DEV checklist environment");
assert.equal(checklist.endpoint, DEV_ENDPOINT, "DEV checklist endpoint");
assert.deepEqual(checklist.artifactObservabilityFields, requiredArtifactFields);
const catalog = await readJSON("deploy/artifact-catalog.dev.json");
assert.equal(catalog.environment, ENVIRONMENT_DEV, "artifact catalog environment");
assert.equal(catalog.endpoint, DEV_ENDPOINT, "artifact catalog endpoint");
assert.equal(catalog.publish.ciPublished, false, "skeleton catalog must not claim CI publish");
assert.equal(catalog.publish.registryVerified, false, "skeleton catalog must not claim registry verification");
assert.deepEqual(catalog.services.map((service) => service.serviceId).sort(), [...SERVICE_IDS].sort());
const m2Fixture = await readJSON("fixtures/dev-deploy-smoke/dev-deploy-smoke.json");
assert.equal(m2Fixture.environment, ENVIRONMENT_DEV, "M2 fixture environment");
assert.equal(m2Fixture.endpoint, DEV_ENDPOINT, "M2 fixture endpoint");
assert.equal(m2Fixture.defaultMode, "dry-run", "M2 fixture default mode");
const runtime = await readJSON("fixtures/mvp/runtime.json");
assert.equal(runtime.endpoints.dev, DEV_ENDPOINT, "MVP runtime endpoint");
const m5Plan = await readJSON("fixtures/mvp/m5-e2e/dry-run-plan.json");
assert.equal(m5Plan.mode, "dry-run", "M5 plan mode");
assert.equal(m5Plan.environment, ENVIRONMENT_DEV, "M5 plan environment");
assert.equal(m5Plan.endpoint, DEV_ENDPOINT, "M5 plan endpoint");
assert.equal(m5Plan.safety.allowNetwork, false, "M5 dry-run network gate");
assert.equal(m5Plan.safety.allowDeploy, false, "M5 dry-run deploy gate");
assert.equal(m5Plan.safety.allowSecrets, false, "M5 dry-run secret gate");
}
async function collectContracts() {
return Promise.all(
contracts.map(async (entry) => {
const paths = await Promise.all(entry.paths.map(async (item) => ({ path: item, exists: await pathExists(item) })));
const missing = paths.filter((item) => !item.exists).map((item) => item.path);
return {
...entry,
status: missing.length === 0 ? entry.status : "blocked_missing_contract_file",
paths,
missing
};
})
);
}
async function collectDevGateReports() {
const entries = await readdir(path.join(repoRoot, "reports/dev-gate"), { withFileTypes: true });
const reports = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const reportPath = path.join("reports/dev-gate", entry.name);
const report = await readJSON(reportPath);
reports.push({
path: reportPath,
issue: report.issue,
taskId: report.taskId,
acceptanceLevel: report.acceptanceLevel,
status: "contract_available",
blockers: Array.isArray(report.blockers) ? report.blockers.length : 0,
notes: report.notes ?? ""
});
}
return reports.sort((a, b) => a.path.localeCompare(b.path));
}
function buildBlockers(collectedContracts) {
const blockers = [
blocker("observability_blocker", "issue-read", "GitHub issue state is not readable in this runner; visible #33-#39 results are limited to repository artifacts and prompt context."),
blocker("network_blocker", "DEV-LIVE", "No repository-local artifact can be treated as live DEV evidence for ingress, edge, frp, D601 router, or downstream HWLAB services."),
blocker("observability_blocker", "artifact-provenance", "The checked artifact catalog is a contract skeleton; it explicitly does not claim real CI publish, registry verification, or deployment."),
blocker("runtime_blocker", "M3/M4 DEV loops", "M3 and M4 currently have local contract smoke evidence only; live DEV trace, audit, operation, evidence, and cleanup IDs are not attached.")
];
for (const item of collectedContracts) {
if (item.missing.length > 0) {
blockers.push(blocker("contract_blocker", item.milestone, `${item.milestone} is missing repository contract file(s): ${item.missing.join(", ")}.`));
}
}
return blockers;
}
function blocker(type, scope, summary) {
return { type, scope, status: "open", source: "BLOCKED", summary };
}
function groupBySource(collectedContracts, devLive, blockers) {
const grouped = Object.fromEntries(levels.map((level) => [level, []]));
for (const item of collectedContracts) {
grouped[item.source].push({
milestone: item.milestone,
supports: item.supports,
status: item.status,
category: item.category,
summary: item.summary
});
}
grouped["DEV-LIVE"].push(...devLive);
grouped.BLOCKED.push(...blockers);
return grouped;
}
function validateOutput(output) {
assert.equal(output.environment, ENVIRONMENT_DEV, "output environment");
assert.equal(output.endpoint, DEV_ENDPOINT, "output endpoint");
assert.equal(output.devOnly, true, "output devOnly");
assert.equal(output.prodDisabled, true, "output prodDisabled");
assert.deepEqual(Object.keys(output.levels), levels, "output levels");
assert.ok(output.blockers.length > 0, "aggregator must not report green without DEV-LIVE evidence");
for (const item of output.blockers) {
assert.ok(blockerTypes.includes(item.type), `unknown blocker type ${item.type}`);
assert.equal(item.status, "open", "aggregator blockers remain open until live evidence is attached");
}
}
async function buildReport() {
await assertStaticContracts();
const evidenceContracts = await collectContracts();
const blockers = buildBlockers(evidenceContracts);
const devLive = [{
milestone: "M2-M5",
issue: "pikasTech/HWLAB#39",
status: "not_available",
category: "DEV-LIVE",
summary: "No live DEV evidence was read or generated by this report-only aggregator."
}];
const output = {
reportVersion: "v1",
taskId: "dev-evidence-blocker-aggregator",
issue: "pikasTech/HWLAB#41",
supports: allSupportedIssues,
generatedFromCommit: gitCommit(),
environment: ENVIRONMENT_DEV,
endpoint: DEV_ENDPOINT,
devOnly: true,
prodDisabled: true,
safety: {
reportOnly: true,
noLiveOperation: true,
noDeploy: true,
noProd: true,
noSecretRead: true,
noHeavyE2E: true,
noBrowserTest: true
},
issueRead: {
status: "blocked",
reason: "GitHub issue data is not available from this runner; this report uses repository files and prompt context only."
},
overall: {
status: "blocked",
green: false,
reason: "SOURCE, LOCAL, and DRY-RUN contracts are visible, but DEV-LIVE evidence and issue reads are blocked."
},
evidenceContracts,
devGateReports: await collectDevGateReports(),
visibleParallelResults,
levels: groupBySource(evidenceContracts, devLive, blockers),
blockers,
nextRoundRecommendations,
validationCommands: [
"node --check scripts/dev-evidence-blocker-aggregator.mjs",
"node scripts/dev-evidence-blocker-aggregator.mjs --check",
"node scripts/dev-evidence-blocker-aggregator.mjs --pretty"
]
};
validateOutput(output);
return output;
function hasFlag(name) {
return process.argv.includes(name);
}
try {
const output = await buildReport();
if (process.argv.includes("--check")) {
process.stdout.write(JSON.stringify({
ok: true,
taskId: output.taskId,
status: output.overall.status,
blockers: output.blockers.length,
sourceLevels: levels
}) + "\n");
const report = await buildReport();
const wroteReports = hasFlag("--write-report");
if (wroteReports) {
const jsonPath = path.join(repoRoot, "reports/dev-gate/dev-m5-gate-aggregator-v2.json");
const markdownPath = path.join(repoRoot, "reports/dev-gate/dev-m5-gate-aggregator-v2.md");
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await writeFile(markdownPath, renderMarkdown(report));
}
if (hasFlag("--check")) {
process.stdout.write(`${JSON.stringify(formatCheckSummary(report))}\n`);
} else if (hasFlag("--markdown")) {
process.stdout.write(renderMarkdown(report));
} else if (wroteReports) {
process.stdout.write(`${JSON.stringify({
...formatCheckSummary(report),
wrote: [
"reports/dev-gate/dev-m5-gate-aggregator-v2.json",
"reports/dev-gate/dev-m5-gate-aggregator-v2.md"
]
})}\n`);
} else {
process.stdout.write(JSON.stringify(output, null, process.argv.includes("--pretty") ? 2 : 0) + "\n");
process.stdout.write(`${JSON.stringify(report, null, hasFlag("--pretty") ? 2 : 0)}\n`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(JSON.stringify({ ok: false, error: message }) + "\n");
process.stderr.write(`${JSON.stringify({ ok: false, error: message })}\n`);
process.exitCode = 1;
}
@@ -0,0 +1,838 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const levels = Object.freeze(["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
const statusRank = Object.freeze({
pass: 0,
"contract-ready": 0,
"manifest-ready": 0,
ready: 0,
published: 0,
blocked: 1,
failed: 1,
not_run: 2,
not_observed: 2,
unavailable: 2,
not_applicable: 3
});
const blockerTypes = new Set([
"contract_blocker",
"environment_blocker",
"network_blocker",
"runtime_blocker",
"agent_blocker",
"observability_blocker",
"safety_blocker"
]);
const reportPaths = Object.freeze({
devPreflight: "reports/dev-gate/dev-preflight-report.json",
devDeploy: "reports/dev-gate/dev-deploy-report.json",
devArtifacts: "reports/dev-gate/dev-artifacts.json",
devEdgeHealth: "reports/dev-gate/dev-edge-health.json",
devM3Hardware: "reports/dev-gate/dev-m3-hardware-loop.json",
devM4Agent: "reports/dev-gate/dev-m4-agent-loop.json",
devM5Gate: "reports/dev-gate/dev-mvp-gate-report.json",
d601Observability: "reports/d601-k3s-readonly-observability.json"
});
const validationCommands = Object.freeze([
"node --check scripts/dev-evidence-blocker-aggregator.mjs",
"node --check scripts/src/dev-evidence-blocker-aggregator.mjs",
"node scripts/dev-evidence-blocker-aggregator.mjs --check",
"node scripts/dev-evidence-blocker-aggregator.mjs --markdown",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
]);
const sourceContracts = Object.freeze([
{
milestone: "M0",
issue: "pikasTech/HWLAB#31",
category: "contract",
level: "SOURCE",
status: "pass",
sources: [
"docs/m0-contract-audit.md",
"protocol/README.md",
"protocol/evidence-chain.md",
"protocol/schemas/evidence-record.schema.json",
"protocol/examples/m0-contract/service-ids.json"
],
commands: [
"node scripts/validate-contract.mjs",
"node scripts/validate-m0-contract.mjs",
"node scripts/validate-evidence-chain.mjs"
],
summary: "Frozen service IDs, JSON-RPC, audit, topology, evidence, and DEV-only deploy contracts are source-ready."
},
{
milestone: "M1",
issue: "pikasTech/HWLAB#7",
category: "local-smoke",
level: "LOCAL",
status: "pass",
sources: [
"docs/m1-local-smoke.md",
"fixtures/mvp/runtime.json",
"scripts/m1-contract-smoke.mjs"
],
commands: ["node scripts/m1-contract-smoke.mjs"],
summary: "Local skeleton smoke covers cloud API, simulators, patch-panel routing, CLI dry-run boundary, and no DEV/PROD mutation."
}
]);
function issue(id) {
return `pikasTech/HWLAB#${id}`;
}
function short(value) {
return typeof value === "string" && value.length > 12 ? value.slice(0, 12) : value ?? "unknown";
}
function oneLine(value) {
return String(value ?? "")
.replace(/\s+/gu, " ")
.trim();
}
function primaryStatus(report) {
return report.status ??
report.gateStatus ??
report.conclusion ??
report.artifactPublish?.status ??
(report.blockers?.length > 0 ? "blocked" : "not_run");
}
function rank(status) {
return statusRank[status] ?? 2;
}
function worstStatus(values) {
const statuses = values.filter(Boolean);
if (statuses.length === 0) return "not_run";
return statuses.sort((a, b) => rank(b) - rank(a))[0];
}
function statusIsPass(status) {
return rank(status) === 0;
}
function statusHasEvidence(status) {
return !["not_run", "not_observed", "unavailable", "not_applicable"].includes(status);
}
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
}
function gitCommit() {
try {
return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
} catch {
return "unknown";
}
}
function normalizeBlocker(blocker, sourceReport, fallbackPriority = "P3") {
assert.ok(blocker && typeof blocker === "object" && !Array.isArray(blocker), "blocker must be an object");
assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`);
const nextTask = oneLine(blocker.nextTask ?? blocker.unblockHint ?? blocker.next ?? "");
return {
id: `${sourceReport.taskId ?? sourceReport.reportKind ?? "report"}:${blocker.scope}`,
priority: fallbackPriority,
type: blocker.type,
scope: blocker.scope,
status: blocker.status ?? "open",
source: sourceReport.path,
sourceIssue: sourceReport.issue,
summary: oneLine(blocker.summary),
...(nextTask ? { nextTask } : {})
};
}
function dedupeBlockers(blockers) {
const byKey = new Map();
for (const blocker of blockers) {
const key = `${blocker.type}:${blocker.scope}:${blocker.summary}`;
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, blocker);
} else if (!existing.nextTask && blocker.nextTask) {
byKey.set(key, { ...existing, nextTask: blocker.nextTask });
} else if (blocker.priority < existing.priority) {
byKey.set(key, { ...blocker, nextTask: blocker.nextTask || existing.nextTask });
}
}
return [...byKey.values()];
}
function hasBlocker(blockers, predicate) {
return blockers.some(predicate);
}
function applyPriority(blocker) {
if (blocker.scope === "base-image") {
return {
...blocker,
priority: "P0",
unblockOrder: 1,
unblocks: [issue(35), issue(33)],
rationale: "Artifact publishing cannot start until an approved Node 20 DEV builder base image is available."
};
}
if (
blocker.scope === "hwlab-router" ||
blocker.scope === "hwlab-tunnel-client" ||
blocker.scope === "hwlab-edge-proxy"
) {
return {
...blocker,
priority: "P0",
unblockOrder: 2,
unblocks: [issue(35), issue(36), issue(33)],
rationale: "The DEV route services need real HWLAB runtime entrypoints before image publish, frp, or ingress smoke can be meaningful."
};
}
if (blocker.scope.includes("artifact") || blocker.scope === "ghcr") {
return {
...blocker,
priority: "P1",
unblockOrder: 3,
unblocks: [issue(35), issue(33), issue(39)],
rationale: "The gate cannot promote deploy or runtime observations without immutable image provenance and digests."
};
}
if (blocker.scope === "kubectl" || blocker.scope === "d601-k3s" || blocker.scope.startsWith("d601-")) {
return {
...blocker,
priority: "P1",
unblockOrder: 4,
unblocks: [issue(34), issue(33), issue(36), issue(38)],
rationale: "D601 hwlab-dev cluster state must be observable read-only before any live apply or M3/M4/M5 acceptance."
};
}
if (blocker.scope.includes("cloud-api-db")) {
return {
...blocker,
priority: "P1",
unblockOrder: 5,
unblocks: [issue(34), issue(33), issue(39)],
rationale: "Cloud API DB env and health readiness block runtime health and MVP evidence even if ingress starts responding."
};
}
if (blocker.scope.includes("edge") || blocker.scope.includes("ingress") || blocker.summary.includes("6667")) {
return {
...blocker,
priority: "P2",
unblockOrder: 6,
unblocks: [issue(36), issue(38), issue(37), issue(39)],
rationale: "The :6667/frp path must be reachable before any DEV-LIVE M3, M4, or M5 evidence can be collected."
};
}
return {
...blocker,
priority: blocker.priority ?? "P3",
unblockOrder: 99,
unblocks: [issue(39)],
rationale: "Residual blocker that must be classified before claiming a green DEV gate."
};
}
function sourceEntry(entry) {
return {
milestone: entry.milestone,
issue: entry.issue,
level: entry.level,
status: entry.status,
category: entry.category,
sources: entry.sources,
commands: entry.commands,
summary: entry.summary
};
}
function reportEvidence({ milestone, level, report, status, category, summary, evidence = [], commands = [] }) {
return {
milestone,
issue: report.issue,
taskId: report.taskId ?? report.reportKind,
reportPath: report.path,
commitId: short(report.commitId ?? report.target?.commitId),
level,
status,
category,
commands,
evidence,
summary
};
}
function collectM2Evidence(reports) {
const preflight = reports.devPreflight;
const deploy = reports.devDeploy;
const artifacts = reports.devArtifacts;
const edge = reports.devEdgeHealth;
const d601 = reports.d601Observability;
const artifactIdentity = preflight.artifactIdentity;
return [
reportEvidence({
milestone: "M2",
level: "SOURCE",
report: preflight,
status: "pass",
category: "deploy-manifest",
evidence: [
"source-contract-static=pass",
`artifact catalog state=${artifactIdentity.artifactCatalog.artifactState}`,
`catalog commit=${artifactIdentity.artifactCatalog.commitId}`
],
commands: preflight.validationCommands,
summary: "Deploy manifests, FRP/master-edge contracts, and safety boundary are source-readable and scoped to hwlab-dev."
}),
reportEvidence({
milestone: "M2",
level: "DRY-RUN",
report: deploy,
status: deploy.dryRun?.status ?? primaryStatus(deploy),
category: "deploy-apply",
evidence: deploy.dryRun?.evidence ?? [],
commands: deploy.dryRun?.commands ?? [],
summary: deploy.dryRun?.summary ?? "DEV apply dry-run report is available."
}),
reportEvidence({
milestone: "M2",
level: "DRY-RUN",
report: artifacts,
status: artifacts.artifactPublish?.status ?? primaryStatus(artifacts),
category: "artifact-publish",
evidence: [
`services=${artifacts.artifactPublish?.services?.length ?? 0}`,
`baseImagePreflight=${artifacts.artifactPublish?.baseImagePreflight?.status ?? "unknown"}`,
`published=${artifacts.artifactPublish?.services?.filter((service) => service.status === "published").length ?? 0}/${SERVICE_IDS.length}`
],
commands: artifacts.validationCommands,
summary: "Artifact publish preflight exists but is blocked before any real image publish."
}),
reportEvidence({
milestone: "M2",
level: "SOURCE",
report: edge,
status: edge.edgeHealth?.status ?? primaryStatus(edge),
category: "edge-frp-contract",
evidence: [
`mode=${edge.edgeHealth?.mode ?? "unknown"}`,
`classification=${edge.edgeHealth?.classification ?? "unknown"}`,
`cloudApiDb=${edge.edgeHealth?.contracts?.deploy?.cloudApiDb?.status ?? "unknown"}`
],
commands: edge.validationCommands,
summary: "Committed edge report is contract-only/not-run; it must not be counted as DEV-LIVE."
}),
reportEvidence({
milestone: "M2",
level: "BLOCKED",
report: d601,
status: d601.conclusion,
category: "d601-observability",
evidence: [
`kubectl=${d601.environment?.binaries?.kubectl?.available === true ? "available" : "missing"}`,
`k3s=${d601.environment?.binaries?.k3s?.available === true ? "available" : "missing"}`,
`clusterReadable=${d601.cluster?.readable === true ? "yes" : "no"}`
],
commands: d601.validationCommands,
summary: "D601 k3s observability is blocked; cluster state has not been read."
})
];
}
function collectM3Evidence(reports) {
const m3 = reports.devM3Hardware;
const m5Milestone = reports.devM5Gate.milestones?.find((item) => item.id === "M3");
return [
reportEvidence({
milestone: "M3",
level: "SOURCE",
report: m3,
status: "manifest-ready",
category: "hardware-loop-cardinality",
evidence: m3.readOnlySupplementalEvidence?.map((item) => `${item.id}=${item.status}`) ?? [],
commands: ["node scripts/validate-dev-m3-cardinality.mjs"],
summary: "Static DEV manifest cardinality declares two box simulators, two gateway simulators, and one patch panel."
}),
reportEvidence({
milestone: "M3",
level: "LOCAL",
report: reports.devM5Gate,
status: m5Milestone?.status ?? "not_run",
category: "hardware-loop-local",
evidence: m5Milestone?.evidence ?? [],
commands: m5Milestone?.commands ?? ["node scripts/m3-hardware-loop-smoke.mjs"],
summary: m5Milestone?.summary ?? "No local M3 smoke evidence was attached."
}),
reportEvidence({
milestone: "M3",
level: statusIsPass(m3.liveOperation?.status) ? "DEV-LIVE" : "BLOCKED",
report: m3,
status: m3.liveOperation?.status ?? "not_run",
category: "hardware-loop-live",
evidence: [
`operationId=${m3.liveOperation?.operationId ?? "not_observed"}`,
`traceId=${m3.liveOperation?.traceId ?? "not_observed"}`,
`auditId=${m3.liveOperation?.auditId ?? "not_observed"}`,
`evidenceId=${m3.liveOperation?.evidenceId ?? "not_observed"}`
],
commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"],
summary: m3.liveOperation?.summary ?? "No live M3 hardware operation was observed."
})
];
}
function collectM4Evidence(reports) {
const m4 = reports.devM4Agent;
return [
reportEvidence({
milestone: "M4",
level: "LOCAL",
report: m4,
status: m4.localSmoke?.status ?? "not_run",
category: "agent-loop-local",
evidence: m4.localSmoke?.evidence ?? [],
commands: m4.localSmoke?.commands ?? [],
summary: m4.localSmoke?.summary ?? "No local M4 smoke evidence was attached."
}),
reportEvidence({
milestone: "M4",
level: "DRY-RUN",
report: m4,
status: m4.dryRun?.status ?? "not_run",
category: "agent-loop-dry-run",
evidence: m4.dryRun?.evidence ?? [],
commands: m4.dryRun?.commands ?? [],
summary: m4.dryRun?.summary ?? "No M4 dry-run evidence was attached."
}),
reportEvidence({
milestone: "M4",
level: statusIsPass(m4.livePreflight?.status) ? "DEV-LIVE" : "BLOCKED",
report: m4,
status: m4.livePreflight?.status ?? m4.devPreconditions?.status ?? "not_run",
category: "agent-loop-live-preflight",
evidence: m4.livePreflight?.evidence ?? [],
commands: m4.livePreflight?.commands ?? [],
summary: m4.livePreflight?.summary ?? m4.devPreconditions?.summary ?? "No live M4 observation was recorded."
})
];
}
function collectM5Evidence(reports) {
const m5 = reports.devM5Gate;
return [
reportEvidence({
milestone: "M5",
level: "DRY-RUN",
report: m5,
status: m5.dryRun?.status ?? "not_run",
category: "mvp-e2e-dry-run",
evidence: m5.dryRun?.evidence ?? [],
commands: m5.dryRun?.commands ?? [],
summary: m5.dryRun?.summary ?? "No M5 dry-run evidence was attached."
}),
reportEvidence({
milestone: "M5",
level: statusIsPass(m5.devPreconditions?.status) ? "DEV-LIVE" : "BLOCKED",
report: m5,
status: m5.devPreconditions?.status ?? m5.gateStatus ?? "blocked",
category: "mvp-e2e-live",
evidence: m5.devPreconditions?.evidence ?? [],
commands: m5.devPreconditions?.commands ?? [],
summary: m5.devPreconditions?.summary ?? "No live M5 e2e evidence was attached."
})
];
}
function collectBlockers(reports) {
const blockers = [];
for (const report of Object.values(reports)) {
for (const blocker of report.blockers ?? []) {
blockers.push(normalizeBlocker(blocker, report));
}
for (const blocker of report.artifactPublish?.blockers ?? []) {
blockers.push(normalizeBlocker(blocker, report));
}
for (const blocker of report.devDeployApply?.remainingBlockers ?? []) {
blockers.push(normalizeBlocker(blocker, report));
}
}
const normalized = dedupeBlockers(blockers).map(applyPriority);
return normalized.sort((a, b) => a.unblockOrder - b.unblockOrder || a.id.localeCompare(b.id));
}
function deriveMilestones(evidence, blockers) {
const evidenceByMilestone = new Map();
for (const item of evidence) {
if (!evidenceByMilestone.has(item.milestone)) {
evidenceByMilestone.set(item.milestone, []);
}
evidenceByMilestone.get(item.milestone).push(item);
}
return ["M0", "M1", "M2", "M3", "M4", "M5"].map((milestone) => {
const items = evidenceByMilestone.get(milestone) ?? [];
const liveItems = items.filter((item) => item.level === "DEV-LIVE");
const highestVisibleLevel = levels.findLast((level) => items.some((item) => item.level === level && level !== "BLOCKED" && statusHasEvidence(item.status))) ?? "BLOCKED";
const hasPassingLive = liveItems.some((item) => statusIsPass(item.status));
const milestoneBlockers = blockers.filter((blocker) => {
if (milestone === "M2") return blocker.unblocks.includes(issue(33)) || blocker.unblocks.includes(issue(35)) || blocker.unblocks.includes(issue(36));
if (milestone === "M3") return blocker.unblocks.includes(issue(38));
if (milestone === "M4") return blocker.unblocks.includes(issue(37));
if (milestone === "M5") return blocker.unblocks.includes(issue(39));
return false;
});
const status = milestoneBlockers.length > 0 || (liveItems.length > 0 && !hasPassingLive)
? "blocked"
: worstStatus(items.map((item) => item.status));
return {
id: milestone,
status,
highestVisibleLevel,
liveEvidence: hasPassingLive ? "pass" : "missing_or_blocked",
evidenceCount: items.length,
blockerCount: milestoneBlockers.length,
summary: summarizeMilestone(milestone, highestVisibleLevel, status)
};
});
}
function summarizeMilestone(milestone, highestVisibleLevel, status) {
const labels = {
M0: "contract source is available",
M1: "local smoke is available",
M2: "deploy/runtime readiness is blocked before live DEV",
M3: "hardware loop has source/local shape but no live operation",
M4: "agent loop has local smoke but live preflight is blocked",
M5: "dry-run is green but live MVP gate is blocked"
};
return `${labels[milestone]}; highest visible level is ${highestVisibleLevel}; status is ${status}.`;
}
function groupByLevel(evidence, blockers) {
const grouped = Object.fromEntries(levels.map((level) => [level, []]));
for (const item of evidence) {
grouped[item.level].push({
milestone: item.milestone,
issue: item.issue,
taskId: item.taskId,
status: item.status,
category: item.category,
reportPath: item.reportPath,
summary: item.summary
});
}
grouped.BLOCKED.push(
...blockers.map((blocker) => ({
priority: blocker.priority,
order: blocker.unblockOrder,
type: blocker.type,
scope: blocker.scope,
sourceIssue: blocker.sourceIssue,
source: blocker.source,
summary: blocker.summary,
nextTask: blocker.nextTask
}))
);
return grouped;
}
function buildDoD(reports, milestones, blockers) {
const m0 = milestones.find((item) => item.id === "M0");
const m1 = milestones.find((item) => item.id === "M1");
const m5 = milestones.find((item) => item.id === "M5");
const preflight = reports.devPreflight;
const artifactIdentity = preflight.artifactIdentity;
const d601 = reports.d601Observability;
return {
status: blockers.length === 0 && milestones.every((item) => item.status === "pass") ? "green" : "blocked",
green: blockers.length === 0 && milestones.every((item) => item.status === "pass"),
checks: [
{
id: "m0-source-contract",
status: m0?.status === "pass" ? "pass" : "blocked",
evidenceLevel: "SOURCE",
summary: "M0 contract checks are source-level evidence only."
},
{
id: "m1-local-smoke",
status: m1?.status === "pass" ? "pass" : "blocked",
evidenceLevel: "LOCAL",
summary: "M1 local smoke is not a live DEV substitute."
},
{
id: "artifact-publish-digests",
status: artifactIdentity.publishVerified ? "pass" : "blocked",
evidenceLevel: artifactIdentity.publishVerified ? "DEV-LIVE" : "BLOCKED",
summary: `artifactState=${artifactIdentity.artifactCatalog.artifactState}, ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, sha256=${artifactIdentity.artifactCatalog.digestCounts.sha256}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}`
},
{
id: "d601-k3s-observability",
status: d601.cluster?.readable ? "pass" : "blocked",
evidenceLevel: d601.cluster?.readable ? "DEV-LIVE" : "BLOCKED",
summary: d601.cluster?.readable
? "D601 hwlab-dev cluster was readable."
: "D601 runner lacks kubectl/k3s/kubeconfig observability for hwlab-dev."
},
{
id: "dev-edge-frp-6667",
status: hasBlocker(blockers, (blocker) => blocker.scope.includes("edge") || blocker.scope.includes("ingress") || blocker.summary.includes("6667")) ? "blocked" : "pass",
evidenceLevel: "BLOCKED",
summary: "No committed report proves live HTTP 200/JSON on http://74.48.78.17:6667."
},
{
id: "cloud-api-db-ready",
status: hasBlocker(blockers, (blocker) => blocker.scope.includes("cloud-api-db")) ? "blocked" : "pass",
evidenceLevel: "BLOCKED",
summary: "Manifest-level DB env exists, but live DB health readiness is still blocked/missing."
},
{
id: "m5-mvp-dev-live",
status: m5?.status === "pass" && m5.liveEvidence === "pass" ? "pass" : "blocked",
evidenceLevel: "BLOCKED",
summary: "M5 dry-run passed; bounded DEV-LIVE MVP e2e has not passed."
}
]
};
}
function buildNextSteps(blockers) {
const byOrder = new Map();
for (const blocker of blockers) {
if (!byOrder.has(blocker.unblockOrder)) {
byOrder.set(blocker.unblockOrder, []);
}
byOrder.get(blocker.unblockOrder).push(blocker);
}
return [...byOrder.entries()]
.sort(([a], [b]) => a - b)
.map(([blockerOrder, items], index) => {
const first = items[0];
return {
order: index + 1,
blockerOrder,
priority: first.priority,
scopes: [...new Set(items.map((item) => item.scope))],
sourceIssues: [...new Set(items.flatMap((item) => [item.sourceIssue, ...item.unblocks].filter(Boolean)))].sort(),
rationale: first.rationale,
action: actionForBlockerOrder(blockerOrder, items),
evidenceRequired: evidenceRequiredFor(first.scope)
};
});
}
function actionForBlockerOrder(blockerOrder, items) {
if (blockerOrder === 2) {
const scopes = items.map((item) => item.scope).sort().join(", ");
return `Add real runtime entrypoints or dedicated Dockerfiles for ${scopes}, then rerun the artifact publish preflight.`;
}
return items.find((item) => item.nextTask)?.nextTask || fallbackAction(items[0].scope);
}
function fallbackAction(scope) {
if (scope === "base-image") return "Preload or tag node:20-bookworm-slim and rerun the base-image and artifact publish preflights.";
if (scope.includes("artifact")) return "Publish DEV artifacts for every frozen HWLAB service and record immutable registry digests.";
if (scope.includes("edge") || scope.includes("ingress")) return "Repair frp/master-edge/D601 router path and rerun read-only DEV edge health.";
if (scope.includes("cloud-api-db")) return "Configure DEV cloud-api DB env readiness and rerun health/preflight without exposing secrets.";
if (scope.includes("kubectl") || scope.includes("k3s")) return "Provide read-only kubectl/kubeconfig observability for hwlab-dev.";
return "Resolve the blocker and attach source/local/dry-run/DEV-live evidence at the correct level.";
}
function evidenceRequiredFor(scope) {
if (scope === "base-image") return "Base-image preflight status=ready with approved Node 20 builder base and no UniDesk/runtime substitute.";
if (scope === "hwlab-router" || scope === "hwlab-tunnel-client" || scope === "hwlab-edge-proxy") return "Real repo entrypoints or dedicated Dockerfiles for route services, followed by artifact preflight.";
if (scope.includes("artifact") || scope === "ghcr") return "Artifact publish report with ciPublished=true, registryVerified=true, and sha256 digest for each frozen service ID.";
if (scope.includes("kubectl") || scope.includes("k3s")) return "Read-only kubectl/k3s report proving pods/services/configmaps are observable in hwlab-dev without reading Secrets.";
if (scope.includes("cloud-api-db")) return "Cloud API health/live output showing DB env ready and redacted secret references, without secret material.";
if (scope.includes("edge") || scope.includes("ingress")) return "Read-only DEV route observation for :6667/frp/edge/router with HWLAB service identity and artifact identity.";
return "A committed report with the exact evidence level and command used.";
}
function validateReport(report) {
assert.equal(report.reportVersion, "v2", "reportVersion");
assert.equal(report.issue, issue(58), "issue");
assert.equal(report.environment, ENVIRONMENT_DEV, "environment");
assert.equal(report.endpoint, DEV_ENDPOINT, "endpoint");
assert.equal(report.devOnly, true, "devOnly");
assert.equal(report.prodDisabled, true, "prodDisabled");
assert.deepEqual(Object.keys(report.levels), [...levels], "level keys");
assert.ok(report.blockers.length >= 1, "must remain blocked without DEV-LIVE evidence");
assert.equal(report.overall.status, "blocked", "overall status");
assert.ok(report.nextSteps.length >= 1, "next steps required");
for (const blocker of report.blockers) {
assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`);
assert.match(blocker.priority, /^P[0-3]$/u, `invalid priority ${blocker.priority}`);
assert.equal(blocker.status, "open", "blocker status");
}
}
export async function buildReport() {
const entries = await Promise.all(
Object.entries(reportPaths).map(async ([key, relativePath]) => {
const report = await readJSON(relativePath);
return [key, { ...report, path: relativePath }];
})
);
const reports = Object.fromEntries(entries);
const evidence = [
...sourceContracts.map(sourceEntry),
...collectM2Evidence(reports),
...collectM3Evidence(reports),
...collectM4Evidence(reports),
...collectM5Evidence(reports)
];
const blockers = collectBlockers(reports);
const milestones = deriveMilestones(evidence, blockers);
const dod = buildDoD(reports, milestones, blockers);
const nextSteps = buildNextSteps(blockers);
const report = {
"$schema": "https://hwlab.pikastech.local/schemas/dev-m5-gate-aggregator-v2.schema.json",
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-m5-gate-aggregator-v2.json",
reportVersion: "v2",
reportKind: "dev-m5-gate-aggregator",
issue: issue(58),
supports: [issue(7), issue(9), issue(31), issue(33), issue(34), issue(35), issue(36), issue(37), issue(38), issue(39), issue(46)],
generatedAt: new Date().toISOString(),
generatedFromCommit: gitCommit(),
environment: ENVIRONMENT_DEV,
endpoint: DEV_ENDPOINT,
devOnly: true,
prodDisabled: true,
safety: {
reportOnly: true,
noDeploy: true,
noProd: true,
noSecretRead: true,
noRuntimeRestart: true,
noLiveProbe: true,
noHeavyE2E: true,
noUniDeskRuntimeSubstitute: true
},
sourceReports: Object.fromEntries(
Object.entries(reports).map(([key, report]) => [
key,
{
path: report.path,
issue: report.issue,
taskId: report.taskId ?? report.reportKind,
status: primaryStatus(report),
commitId: short(report.commitId ?? report.target?.commitId)
}
])
),
overall: {
status: dod.status,
green: dod.green,
reason: dod.green
? "All #9 DEV DoD checks are green."
: "SOURCE, LOCAL, and DRY-RUN evidence exists, but DEV-LIVE acceptance is blocked by artifact, observability, DB, edge/frp, and loop evidence gaps."
},
dod,
milestones,
evidence,
levels: groupByLevel(evidence, blockers),
blockers,
nextSteps,
validationCommands
};
validateReport(report);
return report;
}
export function formatCheckSummary(report) {
return {
ok: true,
taskId: report.reportKind,
issue: report.issue,
status: report.overall.status,
green: report.overall.green,
blockers: report.blockers.length,
nextStep: report.nextSteps[0]?.action ?? null,
levels
};
}
export function renderMarkdown(report) {
const milestoneLines = report.milestones
.map((milestone) => `| ${milestone.id} | ${milestone.status} | ${milestone.highestVisibleLevel} | ${milestone.liveEvidence} | ${milestone.summary} |`)
.join("\n");
const dodLines = report.dod.checks
.map((check) => `| ${check.id} | ${check.status} | ${check.evidenceLevel} | ${check.summary} |`)
.join("\n");
const blockerLines = report.blockers
.map((blocker) => `| ${blocker.priority} | ${blocker.unblockOrder} | ${blocker.type} | ${blocker.scope} | ${blocker.summary} |`)
.join("\n");
const nextLines = report.nextSteps
.map((step) => `${step.order}. ${step.action}\n Evidence required: ${step.evidenceRequired}`)
.join("\n");
return `# HWLAB M5 DEV Gate Aggregator v2
Status: ${report.overall.status}
Generated from: \`${report.generatedFromCommit}\`
Scope: DEV only, report-only
## Summary
${report.overall.reason}
## Milestones
| Milestone | Status | Highest visible level | Live evidence | Summary |
| --- | --- | --- | --- | --- |
${milestoneLines}
## #9 DoD Checks
| Check | Status | Evidence level | Summary |
| --- | --- | --- | --- |
${dodLines}
## Blockers
| Priority | Order | Type | Scope | Summary |
| --- | ---: | --- | --- | --- |
${blockerLines}
## Next Unblock Order
${nextLines}
## Validation
${report.validationCommands.map((command) => `- \`${command}\``).join("\n")}
## Boundary
This report reads committed reports and fixtures only. It does not deploy, call DEV, call PROD, read secrets, restart runtime, run heavy e2e, or substitute UniDesk runtime for HWLAB runtime.
`;
}
+256 -1
View File
@@ -18,7 +18,8 @@ const issueFamily = Object.freeze({
DEV_M3_HARDWARE_LOOP: "pikasTech/HWLAB#38",
DEV_M5_GATE_REPORT: "pikasTech/HWLAB#39",
DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40",
DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41"
DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41",
DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58"
});
const allowedIssues = new Set([
contractIssue,
@@ -26,10 +27,19 @@ const allowedIssues = new Set([
]);
const requiredPreflightIssue = issueFamily.DEV_GATE_PREFLIGHT;
const requiredMilestoneIds = ["M0", "M1", "M2", "M3", "M4", "M5"];
const evidenceLevels = new Set(["SOURCE", "LOCAL", "DRY-RUN", "DEV-LIVE", "BLOCKED"]);
const requiredValidationCommands = [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
];
const requiredAggregatorV2Commands = [
"node --check scripts/dev-evidence-blocker-aggregator.mjs",
"node --check scripts/src/dev-evidence-blocker-aggregator.mjs",
"node scripts/dev-evidence-blocker-aggregator.mjs --check",
"node scripts/dev-evidence-blocker-aggregator.mjs --markdown",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
];
const requiredDevDeployApplySupports = [
"pikasTech/HWLAB#50",
"pikasTech/HWLAB#7",
@@ -277,6 +287,10 @@ async function validateReport(relativePath) {
await validatePreflightReport(relativePath, report);
return;
}
if (report.reportKind === "dev-m5-gate-aggregator") {
await validateAggregatorV2Report(relativePath, report);
return;
}
const label = relativePath;
assertObject(report, label);
@@ -1091,6 +1105,247 @@ async function validatePreflightReport(relativePath, report) {
}
}
function assertEvidenceLevel(value, label) {
assertString(value, label);
assert.ok(evidenceLevels.has(value), `${label} must be one of ${Array.from(evidenceLevels).join(", ")}`);
}
function assertBoolean(value, label) {
assert.equal(typeof value, "boolean", `${label} must be a boolean`);
}
async function validateAggregatorV2Report(relativePath, report) {
const label = relativePath;
assertObject(report, label);
for (const field of [
"$schema",
"$id",
"reportVersion",
"reportKind",
"issue",
"supports",
"generatedAt",
"generatedFromCommit",
"environment",
"endpoint",
"devOnly",
"prodDisabled",
"safety",
"sourceReports",
"overall",
"dod",
"milestones",
"evidence",
"levels",
"blockers",
"nextSteps",
"validationCommands"
]) {
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
}
assertString(report.$schema, `${label}.$schema`);
assertString(report.$id, `${label}.$id`);
assert.equal(report.reportVersion, "v2", `${label}.reportVersion`);
assert.equal(report.reportKind, "dev-m5-gate-aggregator", `${label}.reportKind`);
assert.equal(report.issue, issueFamily.DEV_M5_GATE_AGGREGATOR_V2, `${label}.issue`);
assertTimestamp(report.generatedAt, `${label}.generatedAt`);
assert.match(report.generatedFromCommit, /^([a-f0-9]{7,40}|unknown)$/, `${label}.generatedFromCommit`);
assert.equal(report.environment, "dev", `${label}.environment`);
assert.equal(report.endpoint, "http://74.48.78.17:6667", `${label}.endpoint`);
assert.equal(report.devOnly, true, `${label}.devOnly`);
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
assertStringArray(report.supports, `${label}.supports`, { minLength: 10 });
for (const requiredIssue of [
"pikasTech/HWLAB#7",
"pikasTech/HWLAB#9",
"pikasTech/HWLAB#33",
"pikasTech/HWLAB#34",
"pikasTech/HWLAB#35",
"pikasTech/HWLAB#36",
"pikasTech/HWLAB#37",
"pikasTech/HWLAB#38",
"pikasTech/HWLAB#39",
"pikasTech/HWLAB#46"
]) {
assert.ok(report.supports.includes(requiredIssue), `${label}.supports missing ${requiredIssue}`);
}
assertObject(report.safety, `${label}.safety`);
for (const field of [
"reportOnly",
"noDeploy",
"noProd",
"noSecretRead",
"noRuntimeRestart",
"noLiveProbe",
"noHeavyE2E",
"noUniDeskRuntimeSubstitute"
]) {
assert.equal(report.safety[field], true, `${label}.safety.${field}`);
}
assertObject(report.sourceReports, `${label}.sourceReports`);
for (const requiredReport of [
"devPreflight",
"devDeploy",
"devArtifacts",
"devEdgeHealth",
"devM3Hardware",
"devM4Agent",
"devM5Gate",
"d601Observability"
]) {
assert.ok(Object.hasOwn(report.sourceReports, requiredReport), `${label}.sourceReports missing ${requiredReport}`);
const sourceReport = report.sourceReports[requiredReport];
assertObject(sourceReport, `${label}.sourceReports.${requiredReport}`);
for (const field of ["path", "issue", "taskId", "status", "commitId"]) {
assert.ok(Object.hasOwn(sourceReport, field), `${label}.sourceReports.${requiredReport} missing ${field}`);
assertString(sourceReport[field], `${label}.sourceReports.${requiredReport}.${field}`);
}
assertRepoRelativePath(sourceReport.path, `${label}.sourceReports.${requiredReport}.path`);
}
assertObject(report.overall, `${label}.overall`);
assert.ok(["green", "blocked"].includes(report.overall.status), `${label}.overall.status`);
assertBoolean(report.overall.green, `${label}.overall.green`);
assertString(report.overall.reason, `${label}.overall.reason`);
assert.equal(report.overall.green, report.overall.status === "green", `${label}.overall.green must match status`);
assertObject(report.dod, `${label}.dod`);
assert.ok(["green", "blocked"].includes(report.dod.status), `${label}.dod.status`);
assertBoolean(report.dod.green, `${label}.dod.green`);
assertArray(report.dod.checks, `${label}.dod.checks`);
assert.ok(report.dod.checks.length >= 7, `${label}.dod.checks must cover #9 DoD gates`);
assertUnique(report.dod.checks.map((check) => check.id), `${label}.dod.checks`);
for (const [index, check] of report.dod.checks.entries()) {
const checkLabel = `${label}.dod.checks[${index}]`;
assertObject(check, checkLabel);
for (const field of ["id", "status", "evidenceLevel", "summary"]) {
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
}
assert.match(check.id, /^[a-z][a-z0-9-]*$/, `${checkLabel}.id`);
assert.ok(["pass", "blocked"].includes(check.status), `${checkLabel}.status`);
assertEvidenceLevel(check.evidenceLevel, `${checkLabel}.evidenceLevel`);
assertString(check.summary, `${checkLabel}.summary`);
}
assertArray(report.milestones, `${label}.milestones`);
assert.deepEqual(
report.milestones.map((milestone) => milestone.id),
requiredMilestoneIds,
`${label}.milestones must cover M0-M5 in order`
);
for (const [index, milestone] of report.milestones.entries()) {
const milestoneLabel = `${label}.milestones[${index}]`;
assertObject(milestone, milestoneLabel);
for (const field of ["id", "status", "highestVisibleLevel", "liveEvidence", "evidenceCount", "blockerCount", "summary"]) {
assert.ok(Object.hasOwn(milestone, field), `${milestoneLabel} missing ${field}`);
}
assertStatus(milestone.status, `${milestoneLabel}.status`);
assertEvidenceLevel(milestone.highestVisibleLevel, `${milestoneLabel}.highestVisibleLevel`);
assert.ok(["pass", "missing_or_blocked"].includes(milestone.liveEvidence), `${milestoneLabel}.liveEvidence`);
assert.ok(Number.isInteger(milestone.evidenceCount), `${milestoneLabel}.evidenceCount`);
assert.ok(Number.isInteger(milestone.blockerCount), `${milestoneLabel}.blockerCount`);
assertString(milestone.summary, `${milestoneLabel}.summary`);
}
assertArray(report.evidence, `${label}.evidence`);
assert.ok(report.evidence.length >= 10, `${label}.evidence must include M0-M5 entries`);
for (const [index, evidence] of report.evidence.entries()) {
const evidenceLabel = `${label}.evidence[${index}]`;
assertObject(evidence, evidenceLabel);
for (const field of ["milestone", "issue", "level", "status", "category", "summary"]) {
assert.ok(Object.hasOwn(evidence, field), `${evidenceLabel} missing ${field}`);
}
assert.match(evidence.milestone, /^M[0-5]$/, `${evidenceLabel}.milestone`);
assertString(evidence.issue, `${evidenceLabel}.issue`);
assertEvidenceLevel(evidence.level, `${evidenceLabel}.level`);
assertString(evidence.status, `${evidenceLabel}.status`);
assertString(evidence.category, `${evidenceLabel}.category`);
assertString(evidence.summary, `${evidenceLabel}.summary`);
if (Object.hasOwn(evidence, "reportPath")) {
assertRepoRelativePath(evidence.reportPath, `${evidenceLabel}.reportPath`);
}
if (Object.hasOwn(evidence, "commands")) {
assertStringArray(evidence.commands, `${evidenceLabel}.commands`);
}
if (Object.hasOwn(evidence, "evidence")) {
assertStringArray(evidence.evidence, `${evidenceLabel}.evidence`);
}
if (Object.hasOwn(evidence, "sources")) {
assertStringArray(evidence.sources, `${evidenceLabel}.sources`);
}
}
assertObject(report.levels, `${label}.levels`);
assert.deepEqual(Object.keys(report.levels), Array.from(evidenceLevels), `${label}.levels keys`);
for (const level of evidenceLevels) {
assertArray(report.levels[level], `${label}.levels.${level}`);
}
assertArray(report.blockers, `${label}.blockers`);
assert.ok(report.blockers.length >= 1, `${label}.blockers must not be empty while blocked`);
for (const [index, blocker] of report.blockers.entries()) {
const blockerLabel = `${label}.blockers[${index}]`;
assertObject(blocker, blockerLabel);
for (const field of ["id", "priority", "type", "scope", "status", "source", "sourceIssue", "summary", "unblockOrder", "unblocks", "rationale"]) {
assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`);
}
assertString(blocker.id, `${blockerLabel}.id`);
assert.match(blocker.priority, /^P[0-3]$/, `${blockerLabel}.priority`);
assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type`);
assertString(blocker.scope, `${blockerLabel}.scope`);
assert.equal(blocker.status, "open", `${blockerLabel}.status`);
assertRepoRelativePath(blocker.source, `${blockerLabel}.source`);
assertString(blocker.sourceIssue, `${blockerLabel}.sourceIssue`);
assertString(blocker.summary, `${blockerLabel}.summary`);
assert.ok(Number.isInteger(blocker.unblockOrder), `${blockerLabel}.unblockOrder`);
assertStringArray(blocker.unblocks, `${blockerLabel}.unblocks`, { minLength: 1 });
assertString(blocker.rationale, `${blockerLabel}.rationale`);
if (Object.hasOwn(blocker, "nextTask")) {
assertString(blocker.nextTask, `${blockerLabel}.nextTask`);
}
}
assertArray(report.nextSteps, `${label}.nextSteps`);
assert.ok(report.nextSteps.length >= 5, `${label}.nextSteps must contain blocker order`);
assert.deepEqual(
report.nextSteps.map((step) => step.order),
[...report.nextSteps].map((step) => step.order).sort((a, b) => a - b),
`${label}.nextSteps must be sorted by order`
);
for (const [index, step] of report.nextSteps.entries()) {
const stepLabel = `${label}.nextSteps[${index}]`;
assertObject(step, stepLabel);
for (const field of ["order", "priority", "scopes", "sourceIssues", "rationale", "action", "evidenceRequired"]) {
assert.ok(Object.hasOwn(step, field), `${stepLabel} missing ${field}`);
}
assert.ok(Number.isInteger(step.order), `${stepLabel}.order`);
if (Object.hasOwn(step, "blockerOrder")) {
assert.ok(Number.isInteger(step.blockerOrder), `${stepLabel}.blockerOrder`);
}
assert.match(step.priority, /^P[0-3]$/, `${stepLabel}.priority`);
assertStringArray(step.scopes, `${stepLabel}.scopes`, { minLength: 1 });
assertStringArray(step.sourceIssues, `${stepLabel}.sourceIssues`, { minLength: 1 });
assertString(step.rationale, `${stepLabel}.rationale`);
assertString(step.action, `${stepLabel}.action`);
assertString(step.evidenceRequired, `${stepLabel}.evidenceRequired`);
}
assertStringArray(report.validationCommands, `${label}.validationCommands`, {
minLength: requiredAggregatorV2Commands.length
});
for (const requiredCommand of requiredAggregatorV2Commands) {
assert.ok(
report.validationCommands.includes(requiredCommand),
`${label}.validationCommands missing ${requiredCommand}`
);
}
}
async function main() {
const reportFiles = await collectReportFiles();
assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");