From 012a89bfe122e8ef09ceb4569a59bd994bcacdf7 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 22:48:38 +0000 Subject: [PATCH] test: tighten M3 preflight blocker classifications --- docs/m3-hardware-loop.md | 16 ++-- scripts/dev-m3-hardware-loop-smoke.mjs | 29 +++++-- scripts/dev-m3-hardware-loop-smoke.test.mjs | 96 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index 059d254d..2e462d3c 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -92,8 +92,9 @@ node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-pr ``` The legacy `--confirmed-non-production` flag is accepted as compatibility, but -new handoffs should use `--expect-non-prod`. The script refuses the default -mode and refuses `--dry-run` combined with `--live`. +new handoffs should use `--expect-non-prod`. Missing or incomplete live +authorization stays on the source/read-only plan path, and `--dry-run` combined +with `--live` remains no-write. The live smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first checks the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the @@ -113,9 +114,14 @@ HWLAB_DEV_PATCH_PANEL_URL ``` Those targets must be two distinct HWLAB DEV simulator services for each -simulator kind, plus one HWLAB DEV patch panel, not UniDesk substitutes. The -script then checks two box simulators, two gateway simulators, active -patch-panel wiring for +box/gateway role, plus one callable HWLAB DEV patch panel, not UniDesk +substitutes. Missing direct targets are classified as `target_missing`, +duplicated box or gateway identities as `identity_not_distinct`, and missing +`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` wiring as +`patch_panel_wiring_missing`. Each of those stop conditions keeps +`liveOperation.status` at `not_run` and prevents the DEV write/read smoke from +starting. The script then checks two box simulators, two gateway simulators, +active patch-panel wiring for `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`, a live direct call that reads `DI1=true`, and returned operation, trace, audit, and evidence identifiers. Fixture-backed local M3 output is never accepted as live DEV diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index 7c07e040..0d11afaa 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -7,7 +7,7 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; @@ -143,7 +143,7 @@ function requireSafetyGates({ flags, dryRun, live }) { const confirmedLive = live && flags.has("--confirm-dev") && - flags.has("--confirmed-non-production") && + (flags.has("--expect-non-prod") || flags.has("--confirmed-non-production")) && !dryRun && !flags.has("--allow-live"); @@ -671,7 +671,8 @@ function createLiveOperationPlan() { failureClassifications: Object.values(m3FailureClassifications), refusalPolicy: [ "no arguments defaults to refusal", - "commands missing any of --live --confirm-dev --confirmed-non-production stay source/read-only", + "commands missing --live, --confirm-dev, or non-production confirmation stay source/read-only", + "non-production confirmation is --expect-non-prod; legacy --confirmed-non-production remains accepted", "a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass" ] }; @@ -1715,9 +1716,21 @@ async function main() { } } -try { - await main(); -} catch (error) { - console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; +export { + classifyDirectTargetBlockerScope, + classifyLiveOperationError, + createLiveOperationPlan, + hasRequiredM3Connection, + m3FailureClassifications, + parseArgs, + requireSafetyGates +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await main(); + } catch (error) { + console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } } diff --git a/scripts/dev-m3-hardware-loop-smoke.test.mjs b/scripts/dev-m3-hardware-loop-smoke.test.mjs index ad2c40e0..4cdc27d0 100644 --- a/scripts/dev-m3-hardware-loop-smoke.test.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.test.mjs @@ -5,6 +5,14 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { + classifyDirectTargetBlockerScope, + createLiveOperationPlan, + hasRequiredM3Connection, + parseArgs, + requireSafetyGates +} from "./dev-m3-hardware-loop-smoke.mjs"; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const testOutputDir = "tmp/dev-m3-hardware-loop-smoke-test"; @@ -57,3 +65,91 @@ test("--dry-run aliases the same source/read-only no-write path", async () => { await rm(path.join(repoRoot, output), { force: true }); } }); + +test("M3 safety gates keep incomplete live commands source/read-only", () => { + assert.deepEqual(requireSafetyGates(parseArgs([])), { + mode: "dry-run", + liveWriteAllowed: false + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev"])), { + mode: "dry-run", + liveWriteAllowed: false + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--dry-run", "--live", "--confirm-dev", "--expect-non-prod"])), { + mode: "dry-run", + liveWriteAllowed: false + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev", "--expect-non-prod"])), { + mode: "live", + liveWriteAllowed: true + }); + assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev", "--confirmed-non-production"])), { + mode: "live", + liveWriteAllowed: true + }); +}); + +test("M3 blocker scopes classify missing identities and patch-panel route gaps", () => { + assert.equal(classifyDirectTargetBlockerScope("m3-service-discovery"), "target_missing"); + assert.equal(classifyDirectTargetBlockerScope("m3-direct-target-missing"), "target_missing"); + assert.equal(classifyDirectTargetBlockerScope("m3-box-simu-identity"), "identity_not_distinct"); + assert.equal(classifyDirectTargetBlockerScope("m3-gateway-simu-identity"), "identity_not_distinct"); + assert.equal(classifyDirectTargetBlockerScope("m3-patch-panel-wiring"), "patch_panel_wiring_missing"); + + const plan = createLiveOperationPlan(); + assert.equal(plan.evidenceLevel, "DRY-RUN"); + assert.equal(plan.liveWriteWillRun, false); + assert.ok(plan.failureClassifications.includes("identity_not_distinct")); + assert.ok(plan.failureClassifications.includes("patch_panel_wiring_missing")); + assert.match(plan.refusalPolicy.join("\n"), /source\/read-only/u); +}); + +test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsimu_2 DI1 wiring", () => { + const wrongRoute = hasRequiredM3Connection({ + status: { + activeConnections: [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_1", + toPort: "DI1" + } + ] + }, + wiring: { + connections: [ + { + from: { resourceId: "res_boxsimu_1", port: "DO1" }, + to: { resourceId: "res_boxsimu_1", port: "DI1" }, + mode: "exclusive" + } + ] + } + }); + assert.equal(Boolean(wrongRoute.active), false); + assert.equal(Boolean(wrongRoute.configured), false); + + const requiredRoute = hasRequiredM3Connection({ + status: { + activeConnections: [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ] + }, + wiring: { + connections: [ + { + from: { resourceId: "res_boxsimu_1", port: "DO1" }, + to: { resourceId: "res_boxsimu_2", port: "DI1" }, + mode: "exclusive" + } + ] + } + }); + assert.equal(Boolean(requiredRoute.active), true); + assert.equal(Boolean(requiredRoute.configured), true); +});