Files
2026-05-22 11:51:02 +00:00

236 lines
7.9 KiB
JavaScript

#!/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 <report.json>",
" node scripts/report-lifecycle.mjs invalidate <report.json> --reason <text> [--deprecated-endpoint <url>] [--replaced-by <path>] [--out <path>]",
"",
"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;
}