diff --git a/package.json b/package.json index 6007f293..3737c686 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/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/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.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/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.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/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.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/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-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 --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && 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 scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.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/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/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/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.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/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.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/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.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/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-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 --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && 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 scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/m3-io-control-e2e.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", @@ -30,6 +30,7 @@ "dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight", "dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish", "docs:validate:m3-rollout": "node scripts/validate-m3-rollout-runbook.mjs", + "m3:io:e2e": "node scripts/m3-io-control-e2e.mjs", "d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs", "runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs", "report:lifecycle:test": "node scripts/report-lifecycle.test.mjs", diff --git a/scripts/m3-io-control-e2e.mjs b/scripts/m3-io-control-e2e.mjs new file mode 100644 index 00000000..d0cc2208 --- /dev/null +++ b/scripts/m3-io-control-e2e.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { + formatM3IoControlE2eFailure, + runM3IoControlE2eCli +} from "./src/m3-io-control-e2e.mjs"; + +try { + process.exitCode = await runM3IoControlE2eCli(process.argv.slice(2)); +} catch (error) { + process.stdout.write(`${JSON.stringify(formatM3IoControlE2eFailure(error), null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/m3-io-control-e2e.test.mjs b/scripts/m3-io-control-e2e.test.mjs new file mode 100644 index 00000000..b83f1007 --- /dev/null +++ b/scripts/m3-io-control-e2e.test.mjs @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + checkFrontendNoDirectRuntimeCalls, + classifyM3IoControlReport, + controlPathReachablePersistenceBlocked, + expectedM3IoLiveSequence, + parseM3IoControlE2eArgs, + requireM3IoControlE2eGates, + resolveM3IoEndpoint, + validateExpectedRequestSchemas, + validateM3IoLiveResponse +} from "./src/m3-io-control-e2e.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("M3 IO E2E default mode is SOURCE/static and never permits mutation", () => { + const args = parseM3IoControlE2eArgs([]); + assert.equal(args.live, false); + assert.equal(args.target, "frontend"); + assert.equal(args.frontendUrl, "http://74.48.78.17:16666/"); + + assert.deepEqual(requireM3IoControlE2eGates(args), { + mode: "source-static", + liveWriteAllowed: false, + mutationAllowed: false + }); +}); + +test("M3 IO E2E live mode requires explicit DEV and non-production confirmations", () => { + assert.throws( + () => requireM3IoControlE2eGates(parseM3IoControlE2eArgs(["--live", "--confirm-dev"])), + /requires --live --confirm-dev --confirmed-non-production/u + ); + assert.throws( + () => requireM3IoControlE2eGates(parseM3IoControlE2eArgs(["--live", "--confirm-dev", "--confirmed-non-production", "--dry-run"])), + /cannot be combined/u + ); + assert.deepEqual( + requireM3IoControlE2eGates(parseM3IoControlE2eArgs(["--live", "--confirm-dev", "--confirmed-non-production"])), + { + mode: "dev-live", + liveWriteAllowed: true, + mutationAllowed: true + } + ); + assert.deepEqual( + requireM3IoControlE2eGates(parseM3IoControlE2eArgs(["--live", "--confirm-dev", "--expect-non-prod"])), + { + mode: "dev-live", + liveWriteAllowed: true, + mutationAllowed: true + } + ); +}); + +test("M3 IO E2E rejects dangerous flags and non-DEV live endpoints", () => { + assert.throws( + () => requireM3IoControlE2eGates(parseM3IoControlE2eArgs(["--prod"])), + /forbidden/u + ); + assert.throws( + () => resolveM3IoEndpoint(parseM3IoControlE2eArgs([ + "--live", + "--confirm-dev", + "--confirmed-non-production", + "--url", + "http://127.0.0.1:16666/" + ])), + /live endpoint must match target/u + ); + assert.equal( + resolveM3IoEndpoint(parseM3IoControlE2eArgs(["--live", "--confirm-dev", "--confirmed-non-production"])).href, + "http://74.48.78.17:16666/v1/m3/io" + ); + assert.throws( + () => resolveM3IoEndpoint(parseM3IoControlE2eArgs([ + "--live", + "--confirm-dev", + "--confirmed-non-production", + "--url", + "http://74.48.78.17:16667/" + ])), + /frontend uses DEV/u + ); + assert.equal( + resolveM3IoEndpoint(parseM3IoControlE2eArgs([ + "--live", + "--confirm-dev", + "--confirmed-non-production", + "--target", + "api" + ])).href, + "http://74.48.78.17:16667/v1/m3/io" + ); +}); + +test("M3 IO E2E source mode CLI prints non-mutating SOURCE/DRY-RUN report", () => { + const stdout = execFileSync("node", ["scripts/m3-io-control-e2e.mjs"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + const report = JSON.parse(stdout); + assert.equal(report.mode, "source-static"); + assert.equal(report.sourceKind, "SOURCE"); + assert.equal(report.liveMutation.attempted, false); + assert.equal(report.liveMutation.allowed, false); + assert.equal(report.dryRunPlan.sourceKind, "DRY-RUN"); + assert.equal(report.dryRunPlan.liveWriteWillRun, false); + assert.equal(report.summary.classification, "source_static_contract_checked"); + assert.equal(report.summary.trustedGreen, false); +}); + +test("M3 IO E2E request schemas are fixed to DO1 write and DI1 read", () => { + const result = validateExpectedRequestSchemas(expectedM3IoLiveSequence()); + assert.equal(result.status, "pass"); + assert.deepEqual( + result.sequence.map((step) => [step.action, step.gatewayId, step.resourceId, step.boxId, step.port, step.value]), + [ + ["do.write", "gwsimu_1", "res_boxsimu_1", "boxsimu_1", "DO1", true], + ["di.read", "gwsimu_2", "res_boxsimu_2", "boxsimu_2", "DI1", undefined], + ["do.write", "gwsimu_1", "res_boxsimu_1", "boxsimu_1", "DO1", false], + ["di.read", "gwsimu_2", "res_boxsimu_2", "boxsimu_2", "DI1", undefined] + ] + ); + + const invalid = expectedM3IoLiveSequence().map((step) => + step.id === "write-do1-true" + ? { ...step, request: { ...step.request, port: "DI1" } } + : step + ); + assert.equal(validateExpectedRequestSchemas(invalid).status, "fail"); +}); + +test("M3 IO E2E classifies reachable control with blocked durable persistence correctly", () => { + const operations = expectedM3IoLiveSequence().map((step) => ({ + id: step.id, + status: "succeeded", + schema: { status: "pass", issues: [] }, + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED", + durable: false + } + })); + const classification = classifyM3IoControlReport({ + checks: [{ id: "source", status: "pass" }], + liveOperations: operations + }); + assert.deepEqual(classification, { + status: "blocked", + classification: controlPathReachablePersistenceBlocked, + trustedGreen: false + }); +}); + +test("M3 IO E2E classifies trusted green only with durable DEV-LIVE evidence", () => { + const operations = expectedM3IoLiveSequence().map((step) => ({ + id: step.id, + status: "succeeded", + schema: { status: "pass", issues: [] }, + evidenceState: { + status: "green", + sourceKind: "DEV-LIVE", + durable: true + } + })); + assert.deepEqual( + classifyM3IoControlReport({ + checks: [{ id: "source", status: "pass" }], + liveOperations: operations + }), + { + status: "pass", + classification: "trusted_green", + trustedGreen: true + } + ); +}); + +test("M3 IO E2E frontend guardrail catches direct gateway or patch-panel calls", () => { + const appSource = ` + async function runM3IoAction() { + return fetchJson("/v1/m3/io", { method: "POST" }); + } + async function other() { + return fetch("/v1"); + } + `; + const pass = checkFrontendNoDirectRuntimeCalls({ + appSource, + artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();' + }); + assert.equal(pass.status, "pass"); + + const fail = checkFrontendNoDirectRuntimeCalls({ + appSource: ` + async function runM3IoAction() { + await fetch("http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101/invoke"); + } + `, + artifactPublisherSource: "" + }); + assert.equal(fail.status, "fail"); + assert.ok(fail.issues.some((issue) => /direct runtime|same-origin|runM3IoAction/u.test(issue))); +}); + +test("M3 IO E2E live response schema requires identifiers and frontendBypass=false", () => { + const [step] = expectedM3IoLiveSequence(); + const valid = validateM3IoLiveResponse({ + status: "succeeded", + accepted: true, + action: step.action, + chain: { + projectId: "prj_m3_hardware_loop", + sourceGatewayId: "gwsimu_1", + sourceGatewaySessionId: "gws_gwsimu_1", + sourceResourceId: "res_boxsimu_1", + sourceBoxId: "boxsimu_1", + sourcePort: "DO1", + targetGatewayId: "gwsimu_2", + targetGatewaySessionId: "gws_gwsimu_2", + targetResourceId: "res_boxsimu_2", + targetBoxId: "boxsimu_2", + targetPort: "DI1", + patchPanelServiceId: "hwlab-patch-panel" + }, + controlPath: { + cloudApi: true, + gatewaySimu: true, + boxSimu: true, + patchPanel: true, + frontendBypass: false + }, + operationId: "op_m3_test", + traceId: "trc_m3_test", + auditId: "aud_m3_test", + evidenceId: "evd_m3_test", + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED" + }, + result: { + value: true + } + }, step); + assert.equal(valid.status, "pass"); + + const invalid = validateM3IoLiveResponse({ + status: "succeeded", + accepted: true, + action: step.action, + chain: {}, + controlPath: { + frontendBypass: true + }, + traceId: "trc_m3_test", + evidenceState: { + status: "blocked" + }, + result: { + value: false + } + }, step); + assert.equal(invalid.status, "fail"); + assert.ok(invalid.issues.some((issue) => /operationId|frontendBypass|chain|result\.value/u.test(issue))); +}); diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs new file mode 100644 index 00000000..6a111757 --- /dev/null +++ b/scripts/src/m3-io-control-e2e.mjs @@ -0,0 +1,917 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + M3_IO_CHAIN, + M3_IO_CONTROL_CONTRACT_VERSION, + M3_IO_CONTROL_ROUTE, + describeM3IoControl +} from "../../internal/cloud/m3-io-control.mjs"; + +export const M3_IO_E2E_CONTRACT_VERSION = "m3-io-control-e2e-v1"; +export const defaultFrontendUrl = "http://74.48.78.17:16666/"; +export const defaultApiUrl = "http://74.48.78.17:16667/v1/m3/io"; +export const controlPathReachablePersistenceBlocked = "control_path_reachable_trusted_persistence_blocked"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const labels = Object.freeze({ + source: "SOURCE", + local: "LOCAL", + dryRun: "DRY-RUN", + devLive: "DEV-LIVE", + blocked: "BLOCKED" +}); + +const forbiddenLiveFlags = Object.freeze([ + "--prod", + "--production", + "--real-hardware", + "--read-secret", + "--read-token", + "--deploy", + "--apply", + "--restart", + "--restart-runtime", + "--restart-unidesk", + "--restart-code-queue", + "--restart-backend-core" +]); + +const valueFlags = new Set(["--report", "--output", "--url", "--api-url", "--target", "--timeout-ms"]); +const booleanFlags = new Set([ + "--live", + "--confirm-dev", + "--confirmed-non-production", + "--expect-non-prod", + "--source", + "--source-static", + "--dry-run", + "--pretty", + ...forbiddenLiveFlags +]); + +export function parseM3IoControlE2eArgs(argv = []) { + const flags = new Set(); + const values = new Map(); + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) { + throw new Error(`unexpected positional argument: ${arg}`); + } + + if (valueFlags.has(arg)) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${arg} requires a value`); + } + values.set(arg.slice(2), value); + index += 1; + continue; + } + + if (!booleanFlags.has(arg)) { + throw new Error(`unknown flag: ${arg}`); + } + flags.add(arg); + } + + const target = values.get("target") ?? (values.has("api-url") ? "api" : "frontend"); + if (!["frontend", "api"].includes(target)) { + throw new Error("--target must be frontend or api"); + } + + const timeoutMs = parsePositiveInteger(values.get("timeout-ms"), 8000); + return { + flags, + values, + live: flags.has("--live"), + source: flags.has("--source") || flags.has("--source-static"), + dryRun: flags.has("--dry-run"), + confirmDev: flags.has("--confirm-dev"), + confirmedNonProduction: flags.has("--confirmed-non-production") || flags.has("--expect-non-prod"), + target, + frontendUrl: values.get("url") ?? defaultFrontendUrl, + apiUrl: values.get("api-url") ?? defaultApiUrl, + reportPath: values.get("report") ?? values.get("output") ?? null, + timeoutMs, + pretty: flags.has("--pretty") + }; +} + +export function requireM3IoControlE2eGates(args) { + for (const flag of forbiddenLiveFlags) { + if (args.flags.has(flag)) { + throw new Error(`${flag} is forbidden for the DEV M3 IO control harness`); + } + } + + if (!args.live) { + return { + mode: "source-static", + liveWriteAllowed: false, + mutationAllowed: false + }; + } + + if (args.dryRun || args.source) { + throw new Error("--live cannot be combined with --dry-run, --source, or --source-static"); + } + + if (!args.confirmDev || !args.confirmedNonProduction) { + throw new Error("live mode requires --live --confirm-dev --confirmed-non-production"); + } + + return { + mode: "dev-live", + liveWriteAllowed: true, + mutationAllowed: true + }; +} + +export function expectedM3IoLiveSequence() { + return [ + { + id: "write-do1-true", + method: "POST", + path: M3_IO_CONTROL_ROUTE, + action: "do.write", + expectedValue: true, + mutatesDevState: true, + request: doWriteRequest(true) + }, + { + id: "read-di1-true", + method: "POST", + path: M3_IO_CONTROL_ROUTE, + action: "di.read", + expectedValue: true, + mutatesDevState: false, + request: diReadRequest() + }, + { + id: "write-do1-false", + method: "POST", + path: M3_IO_CONTROL_ROUTE, + action: "do.write", + expectedValue: false, + mutatesDevState: true, + request: doWriteRequest(false) + }, + { + id: "read-di1-false", + method: "POST", + path: M3_IO_CONTROL_ROUTE, + action: "di.read", + expectedValue: false, + mutatesDevState: false, + request: diReadRequest() + } + ]; +} + +export async function buildM3IoControlSourceReport(options = {}) { + const root = options.repoRoot ?? repoRoot; + const checks = await runM3IoControlSourceChecks({ repoRoot: root }); + const classification = checks.every((check) => check.status === "pass") + ? "source_static_contract_checked" + : "source_contract_failed"; + const status = classification === "source_static_contract_checked" ? "pass" : "blocked"; + + return { + tool: "m3-io-control-e2e", + contractVersion: M3_IO_E2E_CONTRACT_VERSION, + mode: "source-static", + sourceKind: labels.source, + evidenceLabels: evidenceLabelDescriptions(), + liveMutation: { + attempted: false, + allowed: false, + reason: "Default SOURCE/static mode never POSTs to DEV and never mutates simulator state." + }, + endpoints: { + frontendSameOriginBase: defaultFrontendUrl, + frontendSameOriginPath: M3_IO_CONTROL_ROUTE, + cloudApiPath: M3_IO_CONTROL_ROUTE, + directCloudApiEndpoint: defaultApiUrl, + gatewayBoxPatchPanelDirectCallsAllowed: false + }, + dryRunPlan: { + sourceKind: labels.dryRun, + liveWriteWillRun: false, + sequence: expectedM3IoLiveSequence().map((step) => ({ + id: step.id, + method: step.method, + path: step.path, + action: step.action, + expectedValue: step.expectedValue, + mutatesDevState: step.mutatesDevState + })) + }, + checks, + summary: { + status, + classification, + trustedGreen: false, + observedAt: isoNow(), + result: status === "pass" + ? "SOURCE/static M3 IO control contract passed; DEV-LIVE was not run." + : "SOURCE/static M3 IO control contract failed; DEV-LIVE must not run until fixed." + } + }; +} + +export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = {}) { + const [serverSource, m3Source, appSource, htmlSource, artifactPublisherSource] = await Promise.all([ + readRepoText(root, "internal/cloud/server.mjs"), + readRepoText(root, "internal/cloud/m3-io-control.mjs"), + readRepoText(root, "web/hwlab-cloud-web/app.mjs"), + readRepoText(root, "web/hwlab-cloud-web/index.html"), + readRepoText(root, "scripts/dev-artifact-publish.mjs") + ]); + + const contract = describeM3IoControl({ + env: { + HWLAB_M3_IO_CONTROL_ENABLED: "true" + } + }); + const frontendGuardrails = checkFrontendNoDirectRuntimeCalls({ + appSource, + htmlSource, + artifactPublisherSource + }); + const requestSchema = validateExpectedRequestSchemas(expectedM3IoLiveSequence()); + const responseSchema = validateM3IoSourceResponseContract(m3Source); + + return [ + checkResult( + "route-name-contract", + contract.route === M3_IO_CONTROL_ROUTE && + M3_IO_CONTROL_ROUTE === "/v1/m3/io" && + contract.contractVersion === M3_IO_CONTROL_CONTRACT_VERSION, + "cloud-api route is /v1/m3/io with the m3-io-control-v1 contract.", + { + route: contract.route, + contractVersion: contract.contractVersion, + evidenceLevel: labels.source + } + ), + checkResult( + "m3-chain-contract", + routeChainMatches(contract.chain), + "M3 chain is box-simu-1 DO1 -> hwlab-patch-panel -> box-simu-2 DI1.", + { + chain: contract.chain, + evidenceLevel: labels.source + } + ), + checkResult( + "cloud-api-rest-route-contract", + serverSource.includes("url.pathname === M3_IO_CONTROL_ROUTE") && + serverSource.includes("handleM3IoControlHttp") && + serverSource.includes("m3IoControl: describeM3IoControl"), + "cloud-api exposes GET/POST /v1/m3/io and advertises it from /v1.", + { + route: M3_IO_CONTROL_ROUTE, + evidenceLevel: labels.source + } + ), + checkResult( + "request-schema-contract", + requestSchema.status === "pass", + "Expected DEV live sequence uses only do.write on DO1 and di.read on DI1.", + { + issues: requestSchema.issues, + sequence: requestSchema.sequence, + evidenceLevel: labels.dryRun + } + ), + checkResult( + "response-schema-contract", + responseSchema.status === "pass", + "M3 IO responses carry operationId, traceId, auditId, evidenceId, evidenceState, and frontendBypass=false.", + { + issues: responseSchema.issues, + evidenceLevel: labels.source + } + ), + checkResult( + "frontend-same-origin-guardrail", + frontendGuardrails.status === "pass", + "Cloud Web only calls same-origin /v1/m3/io for the user control path and has no direct gateway/box/patch-panel fetches.", + { + fetchTargets: frontendGuardrails.fetchTargets, + issues: frontendGuardrails.issues, + evidenceLevel: labels.source + } + ) + ]; +} + +export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "" }) { + const source = `${appSource}\n${htmlSource}`; + const fetchTargets = literalFetchTargets(appSource); + const issues = []; + const allowedSameOriginTargets = new Set([ + "/help.md", + "/health/live", + "/v1", + "/v1/agent/chat", + "/v1/m3/io", + "/json-rpc" + ]); + + for (const target of fetchTargets) { + if (!target.startsWith("/")) { + issues.push(`frontend fetch target is not same-origin relative: ${target}`); + } + if (target.startsWith("/") && !allowedSameOriginTargets.has(target)) { + issues.push(`frontend fetch target is not in the allowed same-origin set: ${target}`); + } + } + + const directRuntimePatterns = [ + /\bfetch(?:Json)?\(\s*["'`][^"'`]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, + /\bfetch(?:Json)?\(\s*["'`]https?:\/\//iu, + /https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, + /(?:\/invoke|\/sync\/tick)/iu + ]; + for (const pattern of directRuntimePatterns) { + if (pattern.test(source)) { + issues.push(`frontend source matches forbidden direct runtime pattern: ${pattern.source}`); + } + } + + const actionBody = functionBody(appSource, "runM3IoAction"); + if (!/fetchJson\("\/v1\/m3\/io"/u.test(actionBody)) { + issues.push("runM3IoAction must POST through same-origin /v1/m3/io"); + } + if (/fetchJson\(["'`](?!\/v1\/m3\/io)/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) { + issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\")"); + } + if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) { + issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api"); + } + + return { + status: issues.length === 0 ? "pass" : "fail", + fetchTargets, + issues + }; +} + +export function validateExpectedRequestSchemas(sequence) { + const issues = []; + for (const step of sequence) { + if (step.path !== M3_IO_CONTROL_ROUTE || step.method !== "POST") { + issues.push(`${step.id} must POST ${M3_IO_CONTROL_ROUTE}`); + } + if (!["do.write", "di.read"].includes(step.request.action)) { + issues.push(`${step.id} action is invalid`); + } + if (step.request.action === "do.write") { + if ( + step.request.gatewayId !== M3_IO_CHAIN.sourceGatewayId || + step.request.resourceId !== M3_IO_CHAIN.sourceResourceId || + step.request.boxId !== M3_IO_CHAIN.sourceBoxId || + step.request.port !== M3_IO_CHAIN.sourcePort || + typeof step.request.value !== "boolean" + ) { + issues.push(`${step.id} must write ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} with a boolean value`); + } + } + if (step.request.action === "di.read") { + if ( + step.request.gatewayId !== M3_IO_CHAIN.targetGatewayId || + step.request.resourceId !== M3_IO_CHAIN.targetResourceId || + step.request.boxId !== M3_IO_CHAIN.targetBoxId || + step.request.port !== M3_IO_CHAIN.targetPort || + Object.hasOwn(step.request, "value") + ) { + issues.push(`${step.id} must read ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} without a value`); + } + } + } + + return { + status: issues.length === 0 ? "pass" : "fail", + issues, + sequence: sequence.map((step) => ({ + id: step.id, + action: step.request.action, + gatewayId: step.request.gatewayId, + resourceId: step.request.resourceId, + boxId: step.request.boxId, + port: step.request.port, + value: Object.hasOwn(step.request, "value") ? step.request.value : undefined + })) + }; +} + +export function validateM3IoSourceResponseContract(m3Source) { + const requiredPatterns = [ + /operationId:\s*input\.operation\.operationId/u, + /traceId:\s*input\.meta\.traceId/u, + /auditId:\s*records\.auditEvent\.auditId/u, + /evidenceId:\s*records\.evidenceRecord\.evidenceId/u, + /evidenceState:\s*evidenceState\(/u, + /persistence:\s*persistenceSummary\(/u, + /frontendBypass:\s*false/u, + /patchPanelOnlyPropagation:\s*true/u, + /sourceKind:\s*"DEV-LIVE"/u, + /sourceKind:\s*"BLOCKED"/u + ]; + const issues = requiredPatterns + .filter((pattern) => !pattern.test(m3Source)) + .map((pattern) => `missing response contract pattern: ${pattern.source}`); + + return { + status: issues.length === 0 ? "pass" : "fail", + issues + }; +} + +export function validateM3IoLiveResponse(payload, step) { + const issues = []; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { + status: "fail", + issues: ["response body must be a JSON object"] + }; + } + for (const field of ["status", "accepted", "action", "chain", "controlPath", "traceId", "evidenceState"]) { + if (!Object.hasOwn(payload, field)) { + issues.push(`missing ${field}`); + } + } + for (const field of ["operationId", "auditId", "evidenceId"]) { + if (payload.status === "succeeded" && !stringValue(payload[field])) { + issues.push(`missing ${field} on succeeded response`); + } + } + if (payload.action !== step.action) { + issues.push(`expected action ${step.action}, got ${payload.action}`); + } + if (payload.controlPath?.frontendBypass !== false) { + issues.push("controlPath.frontendBypass must be false"); + } + if (payload.controlPath?.cloudApi !== true || payload.controlPath?.gatewaySimu !== true || payload.controlPath?.boxSimu !== true) { + issues.push("controlPath must prove cloudApi, gatewaySimu, and boxSimu were used"); + } + if (step.action === "do.write" && payload.controlPath?.patchPanel !== true) { + issues.push("do.write must prove patchPanel=true"); + } + if (payload.chain && !routeChainMatches(payload.chain)) { + issues.push("response chain does not match the required M3 DO1->DI1 route"); + } + if (payload.status === "succeeded") { + const actualValue = payload.result?.value; + if (actualValue !== step.expectedValue) { + issues.push(`expected result.value ${String(step.expectedValue)}, got ${String(actualValue)}`); + } + } + + return { + status: issues.length === 0 ? "pass" : "fail", + issues + }; +} + +export function classifyM3IoControlReport(report) { + const sourceChecks = report.checks ?? []; + if (sourceChecks.some((check) => check.status !== "pass")) { + return { + status: "blocked", + classification: "source_contract_failed", + trustedGreen: false + }; + } + + const operations = report.liveOperations ?? []; + if (operations.length === 0) { + return { + status: "pass", + classification: "source_static_contract_checked", + trustedGreen: false + }; + } + + if (operations.some((operation) => operation.status === "unreachable" || operation.status === "http_error")) { + return { + status: "blocked", + classification: "control_path_unreachable", + trustedGreen: false + }; + } + + if (operations.some((operation) => operation.status !== "succeeded" || operation.schema?.status === "fail")) { + return { + status: "blocked", + classification: "control_path_blocked", + trustedGreen: false + }; + } + + const allTrusted = operations.every((operation) => + operation.evidenceState?.status === "green" && + operation.evidenceState?.sourceKind === labels.devLive && + operation.evidenceState?.durable === true + ); + + if (!allTrusted) { + return { + status: "blocked", + classification: controlPathReachablePersistenceBlocked, + trustedGreen: false + }; + } + + return { + status: "pass", + classification: "trusted_green", + trustedGreen: true + }; +} + +export async function runM3IoControlLiveReport(args, sourceReport) { + const endpoint = resolveM3IoEndpoint(args); + const contractProbe = await requestJson(endpoint.href, { + method: "GET", + timeoutMs: args.timeoutMs + }); + const operations = []; + const sourceChecks = sourceReport.checks ?? []; + + if (!contractProbe.ok) { + operations.push({ + id: "contract-probe", + status: "unreachable", + sourceKind: labels.devLive, + endpoint: endpoint.href, + httpStatus: contractProbe.status, + error: contractProbe.error + }); + } else if (contractProbe.body?.route !== M3_IO_CONTROL_ROUTE || !routeChainMatches(contractProbe.body?.chain)) { + operations.push({ + id: "contract-probe", + status: "blocked", + sourceKind: labels.devLive, + endpoint: endpoint.href, + httpStatus: contractProbe.status, + error: "live /v1/m3/io contract did not match the source M3 route" + }); + } else { + for (const step of expectedM3IoLiveSequence()) { + const operation = await postM3IoLiveStep(endpoint.href, step, args.timeoutMs); + operations.push(operation); + if (operation.status !== "succeeded") { + break; + } + } + } + + const report = { + ...sourceReport, + mode: "dev-live", + sourceKind: labels.devLive, + liveMutation: { + attempted: true, + allowed: true, + reason: "DEV live mode was explicitly gated by --live --confirm-dev --confirmed-non-production." + }, + endpoints: { + ...sourceReport.endpoints, + selectedTarget: args.target, + selectedEndpoint: endpoint.href, + selectedEndpointKind: endpoint.kind + }, + contractProbe: { + ok: contractProbe.ok, + status: contractProbe.status, + route: contractProbe.body?.route ?? null, + chain: contractProbe.body?.chain ?? null, + error: contractProbe.error ?? null + }, + liveOperations: operations, + summary: { + ...sourceReport.summary, + observedAt: isoNow() + } + }; + report.summary = { + ...report.summary, + ...classifyM3IoControlReport({ checks: sourceChecks, liveOperations: operations }) + }; + report.summary.result = resultTextForClassification(report.summary.classification); + return report; +} + +export function resolveM3IoEndpoint(args) { + const endpoint = args.target === "api" + ? new URL(args.apiUrl) + : new URL(M3_IO_CONTROL_ROUTE, args.frontendUrl); + + const port = endpoint.port || (endpoint.protocol === "http:" ? "80" : "443"); + const allowed = + endpoint.protocol === "http:" && + endpoint.hostname === "74.48.78.17" && + endpoint.pathname === M3_IO_CONTROL_ROUTE && + ((args.target === "frontend" && port === "16666") || (args.target === "api" && port === "16667")); + + if (!allowed) { + throw new Error("live endpoint must match target: frontend uses DEV http://74.48.78.17:16666/v1/m3/io; api uses DEV http://74.48.78.17:16667/v1/m3/io"); + } + + return { + href: endpoint.href, + kind: port === "16666" ? "cloud-web-same-origin" : "cloud-api-direct", + port: Number(port) + }; +} + +export async function runM3IoControlE2eCli(argv = []) { + const args = parseM3IoControlE2eArgs(argv); + const gates = requireM3IoControlE2eGates(args); + const sourceReport = await buildM3IoControlSourceReport(); + const report = gates.mode === "dev-live" + ? await runM3IoControlLiveReport(args, sourceReport) + : sourceReport; + + if (args.reportPath) { + await writeReport(args.reportPath, report); + } + + process.stdout.write(`${JSON.stringify(report, null, args.pretty ? 2 : 0)}\n`); + return report.summary.status === "pass" ? 0 : 1; +} + +export function formatM3IoControlE2eFailure(error) { + return { + tool: "m3-io-control-e2e", + contractVersion: M3_IO_E2E_CONTRACT_VERSION, + status: "error", + sourceKind: labels.blocked, + error: { + message: error instanceof Error ? error.message : String(error) + } + }; +} + +async function postM3IoLiveStep(endpoint, step, timeoutMs) { + const traceId = `trc_m3_io_e2e_${step.id.replaceAll("-", "_")}_${randomUUID()}`; + const requestId = `req_m3_io_e2e_${step.id.replaceAll("-", "_")}_${randomUUID()}`; + const body = { + ...step.request, + projectId: M3_IO_CHAIN.projectId, + traceId, + requestId, + actorId: "usr_hwlab_m3_io_e2e" + }; + const response = await requestJson(endpoint, { + method: "POST", + body, + timeoutMs, + headers: { + "x-trace-id": traceId, + "x-request-id": requestId, + "x-actor-id": "usr_hwlab_m3_io_e2e" + } + }); + + if (!response.ok) { + return { + id: step.id, + action: step.action, + status: response.status === 0 ? "unreachable" : "http_error", + sourceKind: labels.devLive, + endpoint, + httpStatus: response.status, + error: response.error, + request: redactLiveRequest(body) + }; + } + + const schema = validateM3IoLiveResponse(response.body, step); + return { + id: step.id, + action: step.action, + status: response.body?.status === "succeeded" && schema.status === "pass" ? "succeeded" : "blocked", + sourceKind: labels.devLive, + endpoint, + httpStatus: response.status, + request: redactLiveRequest(body), + schema, + operationId: response.body?.operationId ?? null, + traceId: response.body?.traceId ?? traceId, + auditId: response.body?.auditId ?? null, + evidenceId: response.body?.evidenceId ?? null, + evidenceState: response.body?.evidenceState ?? null, + accepted: response.body?.accepted ?? null, + resultValue: response.body?.result?.value, + blocker: response.body?.blocker ?? null, + controlPath: response.body?.controlPath ?? null + }; +} + +async function requestJson(url, { method = "GET", body, timeoutMs = 8000, headers = {} } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method, + headers: body === undefined + ? { + accept: "application/json", + ...headers + } + : { + accept: "application/json", + "content-type": "application/json", + ...headers + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + const text = await response.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + return { + ok: response.ok, + status: response.status, + body: parsed, + error: response.ok ? null : parsed?.error?.message ?? parsed?.blocker?.message ?? response.statusText + }; + } catch (error) { + return { + ok: false, + status: 0, + body: null, + error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message + }; + } finally { + clearTimeout(timer); + } +} + +function doWriteRequest(value) { + return { + action: "do.write", + gatewayId: M3_IO_CHAIN.sourceGatewayId, + resourceId: M3_IO_CHAIN.sourceResourceId, + boxId: M3_IO_CHAIN.sourceBoxId, + port: M3_IO_CHAIN.sourcePort, + value + }; +} + +function diReadRequest() { + return { + action: "di.read", + gatewayId: M3_IO_CHAIN.targetGatewayId, + resourceId: M3_IO_CHAIN.targetResourceId, + boxId: M3_IO_CHAIN.targetBoxId, + port: M3_IO_CHAIN.targetPort + }; +} + +function routeChainMatches(chain) { + return chain?.sourceGatewayId === M3_IO_CHAIN.sourceGatewayId && + chain?.sourceResourceId === M3_IO_CHAIN.sourceResourceId && + chain?.sourceBoxId === M3_IO_CHAIN.sourceBoxId && + chain?.sourcePort === M3_IO_CHAIN.sourcePort && + chain?.targetGatewayId === M3_IO_CHAIN.targetGatewayId && + chain?.targetResourceId === M3_IO_CHAIN.targetResourceId && + chain?.targetBoxId === M3_IO_CHAIN.targetBoxId && + chain?.targetPort === M3_IO_CHAIN.targetPort && + chain?.patchPanelServiceId === M3_IO_CHAIN.patchPanelServiceId; +} + +function checkResult(id, ok, summary, details = {}) { + return { + id, + status: ok ? "pass" : "fail", + sourceKind: details.evidenceLevel ?? labels.source, + summary, + ...details + }; +} + +function literalFetchTargets(source) { + return [...source.matchAll(/\b(?:fetch|fetchJson)\(\s*(["'`])([^"'`]+)\1/gu)] + .map((match) => match[2]) + .filter((target) => !target.includes("${")); +} + +function functionBody(source, functionName) { + const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u")); + if (!match) return ""; + let depth = 0; + for (let index = match.index + match[0].length - 1; index < source.length; index += 1) { + const char = source[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return source.slice(match.index, index + 1); + } + } + return ""; +} + +function resultTextForClassification(classification) { + if (classification === "trusted_green") { + return "DEV-LIVE M3 IO control path and durable trusted records are green."; + } + if (classification === controlPathReachablePersistenceBlocked) { + return "DEV-LIVE control path was reachable, but runtime durable/trusted persistence was blocked; not trusted green."; + } + if (classification === "control_path_blocked") { + return "DEV-LIVE M3 IO control path returned a blocked or schema-invalid operation."; + } + if (classification === "control_path_unreachable") { + return "DEV-LIVE M3 IO control endpoint was unreachable or non-2xx."; + } + if (classification === "source_contract_failed") { + return "SOURCE/static contract failed."; + } + return "SOURCE/static M3 IO control contract passed; DEV-LIVE was not run."; +} + +function evidenceLabelDescriptions() { + return [ + { + label: labels.source, + meaning: "Checked-in source or static contract; no runtime mutation." + }, + { + label: labels.local, + meaning: "Local/in-process fixture evidence only; not a live DEV claim." + }, + { + label: labels.dryRun, + meaning: "Planned sequence or schema validation; no live DEV POST." + }, + { + label: labels.devLive, + meaning: "Explicitly gated DEV HTTP execution through cloud-web/cloud-api only." + }, + { + label: labels.blocked, + meaning: "Cannot promote to trusted green." + } + ]; +} + +function redactLiveRequest(body) { + return { + action: body.action, + gatewayId: body.gatewayId, + resourceId: body.resourceId, + boxId: body.boxId, + port: body.port, + value: Object.hasOwn(body, "value") ? body.value : undefined, + projectId: body.projectId, + traceId: body.traceId, + requestId: body.requestId, + actorId: body.actorId + }; +} + +function stringValue(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function parsePositiveInteger(value, fallback) { + if (value === undefined) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("numeric value must be a positive integer"); + } + return parsed; +} + +async function readRepoText(root, relativePath) { + return readFile(path.join(root, relativePath), "utf8"); +} + +async function writeReport(reportPath, report) { + const absolute = path.resolve(repoRoot, reportPath); + if (!absolute.startsWith(`${repoRoot}${path.sep}`)) { + throw new Error("report path must stay inside the repository"); + } + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, `${JSON.stringify(report, null, 2)}\n`); +} + +function isoNow() { + return new Date().toISOString(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}