#!/usr/bin/env node import assert from "node:assert/strict"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { ACTIVE_DEV_BROWSER_ENDPOINT, ACTIVE_DEV_PUBLIC_ENDPOINT, REPORT_LIFECYCLE_VERSION, deprecatedPublicEndpoints, findForbiddenActiveDeprecatedEndpoints, historicalReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultDeprecatedEndpoint = "http://74.48.78.17:6667"; const statusFields = ["status", "gateStatus", "conclusion"]; function usage() { return [ "Usage:", " node scripts/report-lifecycle.mjs validate ", " node scripts/report-lifecycle.mjs invalidate --reason [--deprecated-endpoint ] [--replaced-by ] [--out ]", "", "Notes:", " validate checks the active/historical endpoint lifecycle contract for one report.", " invalidate marks one report historical/deprecated without changing evidence payloads.", " --out defaults to in-place; use a historical/ path when keeping an old active filename reserved for regenerated evidence." ].join("\n"); } function parseArgs(argv) { if (argv.length === 0 || argv.includes("--help")) { return { help: true }; } const [command, reportPath, ...rest] = argv; const args = { command, reportPath, deprecatedEndpoint: defaultDeprecatedEndpoint, reason: null, replacedBy: null, out: null }; for (let index = 0; index < rest.length; index += 1) { const arg = rest[index]; if (arg === "--deprecated-endpoint") { args.deprecatedEndpoint = rest[++index]; } else if (arg === "--reason") { args.reason = rest[++index]; } else if (arg === "--replaced-by") { args.replacedBy = rest[++index]; } else if (arg === "--out") { args.out = rest[++index]; } else if (arg === "--help") { args.help = true; } else { throw new Error(`unknown argument: ${arg}`); } } if (args.help || !args.command) return args; if (!["validate", "invalidate"].includes(args.command)) { throw new Error(`unknown command: ${args.command}`); } assert.ok(args.reportPath, "report path is required"); if (args.command === "invalidate") { assert.ok(args.reason, "--reason is required for invalidate"); assert.ok( deprecatedPublicEndpoints.includes(args.deprecatedEndpoint), `--deprecated-endpoint must be one of ${deprecatedPublicEndpoints.join(", ")}` ); } return args; } function repoPath(inputPath) { const absolutePath = path.resolve(process.cwd(), inputPath); assert.ok( absolutePath === repoRoot || absolutePath.startsWith(`${repoRoot}${path.sep}`), `${inputPath} must stay inside the repository` ); return absolutePath; } function relativeRepoPath(inputPath) { return path.relative(repoRoot, repoPath(inputPath)).split(path.sep).join("/"); } function assertObject(value, label) { assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); } function assertString(value, label) { assert.equal(typeof value, "string", `${label} must be a string`); assert.ok(value.trim().length > 0, `${label} must not be empty`); } function validateLifecycle(report, raw, label) { assertObject(report.reportLifecycle, `${label}.reportLifecycle`); const lifecycle = report.reportLifecycle; assert.equal(lifecycle.version, REPORT_LIFECYCLE_VERSION, `${label}.reportLifecycle.version`); assert.ok(["active", "historical"].includes(lifecycle.state), `${label}.reportLifecycle.state`); assert.equal(lifecycle.activeEndpoint, ACTIVE_DEV_PUBLIC_ENDPOINT, `${label}.reportLifecycle.activeEndpoint`); assert.equal(lifecycle.activeBrowserEndpoint, ACTIVE_DEV_BROWSER_ENDPOINT, `${label}.reportLifecycle.activeBrowserEndpoint`); assertString(lifecycle.summary, `${label}.reportLifecycle.summary`); if (lifecycle.state === "active") { assert.equal(lifecycle.deprecatedEndpoint, null, `${label}.reportLifecycle.deprecatedEndpoint`); assert.deepEqual( findForbiddenActiveDeprecatedEndpoints(report, raw), [], `${label} active report must not present :6666/:6667 as current public endpoint evidence` ); return "active"; } assert.ok( deprecatedPublicEndpoints.includes(lifecycle.deprecatedEndpoint), `${label}.reportLifecycle.deprecatedEndpoint must name a deprecated public endpoint` ); assert.ok( lifecycle.summary.toLowerCase().includes("historical") || lifecycle.summary.toLowerCase().includes("deprecated"), `${label}.reportLifecycle.summary must explain the historical/deprecated state` ); return "historical"; } function primaryStatus(report) { return report.status ?? report.gateStatus ?? report.conclusion ?? null; } function markNonGreen(report) { let hasTopLevelStatus = false; for (const field of statusFields) { if (Object.hasOwn(report, field)) { hasTopLevelStatus = true; if (["pass", "ready", "green", "published"].includes(report[field])) { report[field] = "blocked"; } } } if (!hasTopLevelStatus) { report.status = "blocked"; } } function appendInvalidationBlocker(report, reason, deprecatedEndpoint) { if (!Array.isArray(report.blockers)) { report.blockers = []; } const scope = "deprecated-public-endpoint"; const exists = report.blockers.some((blocker) => blocker?.scope === scope); if (!exists) { report.blockers.push({ type: "environment_blocker", scope, status: "open", summary: `${reason} Deprecated endpoint ${deprecatedEndpoint} cannot be used as active DEV evidence.` }); } } async function readReport(reportPath) { const absolutePath = repoPath(reportPath); const raw = await readFile(absolutePath, "utf8"); return { absolutePath, raw, report: JSON.parse(raw) }; } async function validateCommand(args) { const relativePath = relativeRepoPath(args.reportPath); const { raw, report } = await readReport(args.reportPath); const state = validateLifecycle(report, raw, relativePath); process.stdout.write(`${JSON.stringify({ ok: true, path: relativePath, state, primaryStatus: primaryStatus(report) })}\n`); } async function invalidateCommand(args) { const inputRelativePath = relativeRepoPath(args.reportPath); const { report } = await readReport(args.reportPath); assertObject(report, inputRelativePath); report.reportLifecycle = historicalReportLifecycle({ deprecatedEndpoint: args.deprecatedEndpoint, summary: `Historical/deprecated after DEV public endpoint change: ${args.reason}` }); if (args.replacedBy) { report.reportLifecycle.replacedBy = relativeRepoPath(args.replacedBy); } markNonGreen(report); appendInvalidationBlocker(report, args.reason, args.deprecatedEndpoint); const outPath = args.out ? repoPath(args.out) : repoPath(args.reportPath); await mkdir(path.dirname(outPath), { recursive: true }); const nextRaw = `${JSON.stringify(report, null, 2)}\n`; validateLifecycle(report, nextRaw, inputRelativePath); await writeFile(outPath, nextRaw); process.stdout.write(`${JSON.stringify({ ok: true, input: inputRelativePath, output: path.relative(repoRoot, outPath).split(path.sep).join("/"), state: "historical", deprecatedEndpoint: args.deprecatedEndpoint, replacedBy: report.reportLifecycle.replacedBy ?? null })}\n`); } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.command) { process.stdout.write(`${usage()}\n`); return; } if (args.command === "validate") { await validateCommand(args); return; } if (args.command === "invalidate") { await invalidateCommand(args); } } try { await main(); } catch (error) { process.stderr.write(`[report-lifecycle] ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }