596 lines
22 KiB
JavaScript
596 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
/* eslint-disable no-console */
|
|
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
export const reportPath = path.join(repoRoot, ".tc/baselines/readiness-docs/baseline-health.md");
|
|
|
|
const ignoredDirectoryNames = new Set([
|
|
".git",
|
|
".cache",
|
|
".next",
|
|
".react-router",
|
|
".tc",
|
|
".turbo",
|
|
".vite",
|
|
"build",
|
|
"cache",
|
|
"coverage",
|
|
"dist",
|
|
"generated",
|
|
"node_modules",
|
|
]);
|
|
|
|
const ignoredFileNames = new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]);
|
|
|
|
export const readinessDocsReadinessInventoryScanRoots = [
|
|
"docs/porting-guide.md",
|
|
"docs/porting-verification.md",
|
|
"docs/production-checklist.md",
|
|
"docs/feature-inventory.md",
|
|
"docs/env-access-inventory.md",
|
|
"docs/deployment.md",
|
|
];
|
|
|
|
export const readinessDocsReadinessInventoryPatterns = [
|
|
{ id: "readiness-signal", pattern: /\b(?:readiness|ready|checklist|baseline|verification|verified|health|production-ready)\b/gi },
|
|
{ id: "deployment-signal", pattern: /\b(?:deploy(?:ment)?|Docker|Coolify|Gitea|registry|workflow|CI|production|release)\b/gi },
|
|
{
|
|
id: "environment-signal",
|
|
pattern: /\b(?:env|environment|secret|DATABASE_URL|DIRECT_URL|REDIS_URL|BULLMQ_REDIS_URL|MAILERSEND_[A-Z0-9_]+|TOKEN|credential|access)\b/g,
|
|
},
|
|
{ id: "feature-plugin-signal", pattern: /\b(?:feature|manifest|plugin|plugin-candidate|core|remove-candidate|inventory|boundary|extraction)\b/gi },
|
|
{ id: "risk-marker", pattern: /\b(?:TODO|TBD|FIXME|XXX|follow-up|manual|missing|required|risk|blocked)\b/gi },
|
|
];
|
|
|
|
export const gates = [
|
|
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
|
|
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
|
|
{
|
|
id: "vitest-readinessDocs-smoke",
|
|
command: "pnpm",
|
|
args: ["exec", "vitest", "run", "scripts/check-api-inventory.test.ts", "apps/web/app/application/featureManifests.test.ts"],
|
|
},
|
|
{ id: "readinessDocs-status-authority-guard", type: "readinessDocs-status-authority-guard" },
|
|
{ id: "readinessDocs-doc-truth-guard", type: "readinessDocs-doc-truth-guard" },
|
|
{ id: "readinessDocs-docs-readiness-inventory", type: "readinessDocs-docs-readiness-inventory" },
|
|
];
|
|
|
|
function shellCommand(gate) {
|
|
if (gate.type === "readinessDocs-docs-readiness-inventory") return "read-only Readiness Docs docs readiness inventory";
|
|
if (gate.type === "readinessDocs-status-authority-guard") return "static readiness authority marker guard";
|
|
if (gate.type === "readinessDocs-doc-truth-guard") return "static doc truth guard";
|
|
return [gate.command, ...gate.args].join(" ");
|
|
}
|
|
|
|
export const readinessDocsStatusAuthorityContracts = [
|
|
{
|
|
path: "docs/improvement-plan/PROGRESS.md",
|
|
requiredMarkers: [
|
|
"READINESS_AUTHORITY: non-authoritative historical tracker",
|
|
"Do not use it to\n> decide deploy readiness, live environment state, or operator closeout.",
|
|
"The\n> canonical readiness source is\n> `docs/improvement-plan/OPERATOR-CHECKLIST.md`",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/BLOCKERS.md",
|
|
requiredMarkers: [
|
|
"READINESS_AUTHORITY: non-authoritative blocker register",
|
|
"is not the deploy\n> readiness source of truth",
|
|
"The canonical readiness source is\n> `docs/improvement-plan/OPERATOR-CHECKLIST.md`",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/OPERATOR-CHECKLIST.md",
|
|
requiredMarkers: [
|
|
"READINESS_AUTHORITY: authoritative operator readiness checklist",
|
|
"the canonical readiness source for operator-pending actions, live env flips,\n> and deploy/operator closeout",
|
|
"PROGRESS.md` and unresolved-risk registers such as\n> `docs/improvement-plan/BLOCKERS.md` are supporting inputs only",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/CURRENT-STATUS.md",
|
|
requiredMarkers: [
|
|
"READINESS_AUTHORITY: status-source map",
|
|
"This file does not replace the\n> referenced runbooks",
|
|
"Use this map before interpreting old `PASS`, `completed`, deploy, or nonstop\nagent notes under `docs/improvement-plan/`",
|
|
"When a historical file and a canonical source disagree, the canonical source\nwins",
|
|
],
|
|
},
|
|
];
|
|
|
|
export const readinessDocsTruthContracts = [
|
|
{
|
|
path: "docs/features/email-sequences.md",
|
|
requiredMarkers: [
|
|
"foundation/admin-preview",
|
|
"not an end-to-end production email-delivery claim",
|
|
"MARKETING_SEQUENCES_ENABLED",
|
|
"Keep it unset or `false` in production",
|
|
"bundled template renderer",
|
|
"`@toolctrl/mail` MailerSend adapter",
|
|
"seed data",
|
|
"delivery webhook production evidence",
|
|
"production scheduled-job registration/deploy evidence",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/feature-inventory.md",
|
|
requiredMarkers: [
|
|
"signed MailerSend bounce/rejection ingress existieren",
|
|
"provider delivery/open/click reconciliation evidence beyond the in-repo tracking routes",
|
|
"MARKETING_SEQUENCES_ENABLED=false",
|
|
],
|
|
forbiddenMarkers: ["Bounce/Provider-Webhooks", "bounce/provider webhooks"],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/05-business-features/README.md",
|
|
requiredMarkers: [
|
|
"Signed MailerSend\nbounce/rejection ingress is shipped",
|
|
"provider delivery/open/click\nreconciliation evidence beyond the in-repo tracking routes",
|
|
"production scheduled-job registration/deploy evidence",
|
|
],
|
|
forbiddenMarkers: ["Bounce/Provider-Webhooks", "bounce/provider webhooks"],
|
|
},
|
|
{
|
|
path: "docs/ops/worker-queues.md",
|
|
requiredMarkers: [
|
|
"Email-sequence subscribers require `MARKETING_SEQUENCES_ENABLED=true`",
|
|
"keep it unset or false in production until the remaining approval follow-ups",
|
|
"docs/features/email-sequences.md",
|
|
"Stale-lead/trial-expiring cron runner functions exist but are not production scheduler-registered yet",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/ops/asset-cdn.md",
|
|
requiredMarkers: [
|
|
"CDN provisioning and `ASSET_CDN_URL` flips remain parked",
|
|
"A7/B2/#51",
|
|
"Do not execute these steps while A7/B2/#51 remains parked",
|
|
"Do not run these as active acceptance checks while CDN rollout remains parked",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md",
|
|
requiredMarkers: [
|
|
"rollout remain backlogged/operator-deferred with A7/B2/#51",
|
|
"This detail plan is historical implementation context",
|
|
"Historical runbook only while #51 is backlogged",
|
|
"These steps are currently parked with A7/B2",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/OPERATOR-CHECKLIST.md",
|
|
requiredMarkers: [
|
|
"A7/#51 is backlogged, not an active CDN item",
|
|
"B-008 is closed; B-012 is mitigated today",
|
|
"B-012 is mitigated today, but the permanent fix is still operator-owned",
|
|
"Parked CDN rows A7/B2/#51 are deliberate",
|
|
],
|
|
},
|
|
{
|
|
path: "docs/improvement-plan/BLOCKERS.md",
|
|
requiredMarkers: [
|
|
"B-012 — Gitea-runner Docker `default-address-pool` recurrence risk",
|
|
"mitigated-now-but-recurs-without-config-change",
|
|
"Blocks:** no active deploy",
|
|
],
|
|
forbiddenMarkers: ["B-008"],
|
|
},
|
|
{
|
|
path: "docs/deployment.md",
|
|
requiredMarkers: ["B-012 is not a repository code blocker", "Docker network exhaustion is operator-owned host hardening"],
|
|
},
|
|
];
|
|
|
|
export const readinessDocsArchivedHandoffDirectory = "docs/improvement-plan/archive/handoffs";
|
|
export const readinessDocsArchivedHandoffHistoricalMarkers = [
|
|
"Historical snapshot only",
|
|
"Historical/imported snapshot only",
|
|
];
|
|
export const readinessDocsArchivedHandoffRequiredMarkers = [
|
|
"Do not treat",
|
|
"current work",
|
|
"OPERATOR-CHECKLIST.md",
|
|
];
|
|
|
|
function outputFingerprint(output) {
|
|
const normalized = output
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.slice(-40)
|
|
.join("\n");
|
|
return { hash: createHash("sha256").update(output).digest("hex").slice(0, 12), excerpt: normalized.slice(0, 1200) || "(no output)" };
|
|
}
|
|
|
|
function parseNumber(value) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
function conservativeFailureCount(count, output) {
|
|
return count > 0 ? count : output.trim() ? 1 : 0;
|
|
}
|
|
|
|
function countVitestFailures(output) {
|
|
const failedTests = output.match(/Tests\s+(\d+)\s+failed/i);
|
|
if (failedTests) return parseNumber(failedTests[1]);
|
|
const failedFiles = output.match(/Test Files\s+(\d+)\s+failed/i);
|
|
return failedFiles ? parseNumber(failedFiles[1]) : 0;
|
|
}
|
|
|
|
export function failureCount(gateId, stdout, stderr, exitCode = 1) {
|
|
if (exitCode === 0 || gateId === "readinessDocs-docs-readiness-inventory") return 0;
|
|
const output = `${stdout}\n${stderr}`;
|
|
if (gateId === "typecheck") return conservativeFailureCount((output.match(/error TS\d+/g) ?? []).length, output);
|
|
if (gateId === "lint") {
|
|
const problemsMatch = output.match(/✖\s+(\d+)\s+problems?\s+\((\d+)\s+errors?/i);
|
|
const compactMatch = output.match(/(\d+)\s+errors?/i);
|
|
return conservativeFailureCount(problemsMatch ? parseNumber(problemsMatch[2]) : compactMatch ? parseNumber(compactMatch[1]) : 0, output);
|
|
}
|
|
if (gateId === "vitest-readinessDocs-smoke") return conservativeFailureCount(countVitestFailures(output), output);
|
|
return output.trim() ? 1 : 0;
|
|
}
|
|
|
|
function runShellGate(gate) {
|
|
return new Promise((resolve) => {
|
|
const startedAt = Date.now();
|
|
const child = spawn(gate.command, gate.args, { cwd: repoRoot, env: { ...process.env, CI: "1" }, shell: false });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", (error) => {
|
|
stderr += `\n${error.message}`;
|
|
});
|
|
child.on("close", (code) => {
|
|
const output = `${stdout}\n${stderr}`;
|
|
resolve({
|
|
id: gate.id,
|
|
command: shellCommand(gate),
|
|
exitCode: code ?? 1,
|
|
durationMs: Date.now() - startedAt,
|
|
failureCount: failureCount(gate.id, stdout, stderr, code ?? 1),
|
|
fingerprint: outputFingerprint(output),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function isIgnoredDirectory(name, relativePath) {
|
|
return ignoredDirectoryNames.has(name) || ignoredDirectoryNames.has(relativePath) || name.endsWith(".cache");
|
|
}
|
|
|
|
function isIgnoredFile(name) {
|
|
return ignoredFileNames.has(name) || name.endsWith(".generated.ts") || name.endsWith(".generated.tsx") || name.endsWith(".min.js") || name.endsWith(".map");
|
|
}
|
|
|
|
async function rootExists(root, rootRelativePath) {
|
|
try {
|
|
await stat(path.join(root, rootRelativePath));
|
|
return true;
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function collectFiles(root, rootRelativePath) {
|
|
const absoluteRoot = path.join(root, rootRelativePath);
|
|
const rootStat = await stat(absoluteRoot);
|
|
if (rootStat.isFile()) return [absoluteRoot];
|
|
const files = [];
|
|
async function walk(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(directory, entry.name);
|
|
const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
|
|
if (entry.isDirectory()) {
|
|
if (isIgnoredDirectory(entry.name, relativePath)) continue;
|
|
await walk(absolutePath);
|
|
} else if (entry.isFile() && /\.(?:mdx?|json)$/.test(entry.name) && !isIgnoredFile(entry.name)) {
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
}
|
|
await walk(absoluteRoot);
|
|
return files;
|
|
}
|
|
|
|
export async function runReadinessDocsReadinessInventory(root = repoRoot) {
|
|
const existingRoots = [];
|
|
const missingRoots = [];
|
|
for (const rootRelativePath of readinessDocsReadinessInventoryScanRoots) {
|
|
if (await rootExists(root, rootRelativePath)) existingRoots.push(rootRelativePath);
|
|
else missingRoots.push(rootRelativePath);
|
|
}
|
|
const files = (await Promise.all(existingRoots.map((rootRelativePath) => collectFiles(root, rootRelativePath)))).flat();
|
|
const counts = new Map(readinessDocsReadinessInventoryPatterns.map(({ id }) => [id, 0]));
|
|
const matches = [];
|
|
for (const file of files) {
|
|
const relativeFile = path.relative(root, file).split(path.sep).join("/");
|
|
const lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
lines.forEach((line, index) => {
|
|
for (const { id, pattern } of readinessDocsReadinessInventoryPatterns) {
|
|
pattern.lastIndex = 0;
|
|
const found = line.match(pattern);
|
|
if (!found) continue;
|
|
counts.set(id, (counts.get(id) ?? 0) + found.length);
|
|
matches.push(`${relativeFile}:${index + 1}: [${id}] ${line.trim()}`);
|
|
}
|
|
});
|
|
}
|
|
return {
|
|
roots: existingRoots,
|
|
missingRoots,
|
|
filesScanned: files.length,
|
|
counts: Object.fromEntries(counts.entries()),
|
|
totalMatches: matches.length,
|
|
matches,
|
|
};
|
|
}
|
|
|
|
async function runReadinessDocsReadinessInventoryGate(gate) {
|
|
const startedAt = Date.now();
|
|
const inventory = await runReadinessDocsReadinessInventory(repoRoot);
|
|
const outputLines = [
|
|
`Readiness Docs docs readiness inventory scanned roots: ${inventory.roots.join(", ") || "(none)"}`,
|
|
`Readiness Docs docs readiness inventory missing roots skipped: ${inventory.missingRoots.join(", ") || "(none)"}`,
|
|
`files scanned: ${inventory.filesScanned}`,
|
|
...Object.entries(inventory.counts).map(([id, count]) => `${id}: ${count}`),
|
|
`total matches: ${inventory.totalMatches}`,
|
|
...inventory.matches.slice(0, 240),
|
|
];
|
|
if (inventory.matches.length > 240) outputLines.push(`... ${inventory.matches.length - 240} additional matches omitted`);
|
|
const output = outputLines.join("\n");
|
|
return {
|
|
id: gate.id,
|
|
command: shellCommand(gate),
|
|
exitCode: 0,
|
|
durationMs: Date.now() - startedAt,
|
|
failureCount: 0,
|
|
inventory,
|
|
fingerprint: outputFingerprint(output),
|
|
};
|
|
}
|
|
|
|
export async function runReadinessDocsStatusAuthorityGuard(root = repoRoot) {
|
|
const failures = [];
|
|
const checked = [];
|
|
for (const contract of readinessDocsStatusAuthorityContracts) {
|
|
const absolutePath = path.join(root, contract.path);
|
|
let contents = "";
|
|
try {
|
|
contents = await readFile(absolutePath, "utf8");
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") {
|
|
failures.push(`${contract.path}: missing authority-controlled document`);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
checked.push(contract.path);
|
|
for (const marker of contract.requiredMarkers) {
|
|
if (!contents.includes(marker)) failures.push(`${contract.path}: missing marker "${marker}"`);
|
|
}
|
|
}
|
|
return { checked, failures };
|
|
}
|
|
|
|
function hasAnyMarker(contents, markers) {
|
|
return markers.some((marker) => contents.includes(marker));
|
|
}
|
|
|
|
export async function runReadinessDocsDocTruthGuard(root = repoRoot) {
|
|
const failures = [];
|
|
const checked = [];
|
|
|
|
for (const contract of readinessDocsTruthContracts) {
|
|
const absolutePath = path.join(root, contract.path);
|
|
let contents = "";
|
|
try {
|
|
contents = await readFile(absolutePath, "utf8");
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") {
|
|
failures.push(`${contract.path}: missing doc-truth-controlled document`);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
checked.push(contract.path);
|
|
for (const marker of contract.requiredMarkers) {
|
|
if (!contents.includes(marker)) failures.push(`${contract.path}: missing marker "${marker}"`);
|
|
}
|
|
for (const marker of contract.forbiddenMarkers ?? []) {
|
|
if (contents.includes(marker)) failures.push(`${contract.path}: forbidden stale marker "${marker}"`);
|
|
}
|
|
}
|
|
|
|
const handoffDirectory = path.join(root, readinessDocsArchivedHandoffDirectory);
|
|
let handoffEntries = [];
|
|
try {
|
|
handoffEntries = await readdir(handoffDirectory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") failures.push(`${readinessDocsArchivedHandoffDirectory}: missing archived handoff directory`);
|
|
else throw error;
|
|
}
|
|
|
|
for (const entry of handoffEntries) {
|
|
if (!entry.isFile() || !/^HANDOFF-.*\.md$/u.test(entry.name)) continue;
|
|
const relativePath = path.posix.join(readinessDocsArchivedHandoffDirectory, entry.name);
|
|
const contents = await readFile(path.join(handoffDirectory, entry.name), "utf8");
|
|
checked.push(relativePath);
|
|
if (!hasAnyMarker(contents, readinessDocsArchivedHandoffHistoricalMarkers)) {
|
|
failures.push(`${relativePath}: missing historical snapshot marker`);
|
|
}
|
|
for (const marker of readinessDocsArchivedHandoffRequiredMarkers) {
|
|
if (!contents.includes(marker)) failures.push(`${relativePath}: missing archived-handoff marker "${marker}"`);
|
|
}
|
|
}
|
|
|
|
return { checked, failures };
|
|
}
|
|
|
|
async function runReadinessDocsStatusAuthorityGuardGate(gate) {
|
|
const startedAt = Date.now();
|
|
const guard = await runReadinessDocsStatusAuthorityGuard(repoRoot);
|
|
const output = [
|
|
`Readiness Docs status authority guard checked: ${guard.checked.join(", ") || "(none)"}`,
|
|
`failures: ${guard.failures.length}`,
|
|
...guard.failures,
|
|
].join("\n");
|
|
return {
|
|
id: gate.id,
|
|
command: shellCommand(gate),
|
|
exitCode: guard.failures.length > 0 ? 1 : 0,
|
|
durationMs: Date.now() - startedAt,
|
|
failureCount: guard.failures.length,
|
|
authorityGuard: guard,
|
|
fingerprint: outputFingerprint(output),
|
|
};
|
|
}
|
|
|
|
async function runReadinessDocsDocTruthGuardGate(gate) {
|
|
const startedAt = Date.now();
|
|
const guard = await runReadinessDocsDocTruthGuard(repoRoot);
|
|
const output = [
|
|
`Readiness Docs doc truth guard checked: ${guard.checked.join(", ") || "(none)"}`,
|
|
`failures: ${guard.failures.length}`,
|
|
...guard.failures,
|
|
].join("\n");
|
|
return {
|
|
id: gate.id,
|
|
command: shellCommand(gate),
|
|
exitCode: guard.failures.length > 0 ? 1 : 0,
|
|
durationMs: Date.now() - startedAt,
|
|
failureCount: guard.failures.length,
|
|
docTruthGuard: guard,
|
|
fingerprint: outputFingerprint(output),
|
|
};
|
|
}
|
|
|
|
async function runGate(gate) {
|
|
if (gate.type === "readinessDocs-status-authority-guard") return runReadinessDocsStatusAuthorityGuardGate(gate);
|
|
if (gate.type === "readinessDocs-doc-truth-guard") return runReadinessDocsDocTruthGuardGate(gate);
|
|
if (gate.type === "readinessDocs-docs-readiness-inventory") return runReadinessDocsReadinessInventoryGate(gate);
|
|
return runShellGate(gate);
|
|
}
|
|
|
|
function renderAuthorityGuard(result) {
|
|
if (!result.authorityGuard) return [];
|
|
return [
|
|
"- Authority Docs Checked: " + (result.authorityGuard.checked.join(", ") || "(none)"),
|
|
`- Authority Guard Failures: ${result.authorityGuard.failures.length}`,
|
|
...result.authorityGuard.failures.map((failure) => ` - ${failure}`),
|
|
];
|
|
}
|
|
|
|
function renderDocTruthGuard(result) {
|
|
if (!result.docTruthGuard) return [];
|
|
return [
|
|
"- Doc Truth Docs Checked: " + (result.docTruthGuard.checked.join(", ") || "(none)"),
|
|
`- Doc Truth Guard Failures: ${result.docTruthGuard.failures.length}`,
|
|
...result.docTruthGuard.failures.map((failure) => ` - ${failure}`),
|
|
];
|
|
}
|
|
|
|
function renderInventoryCounts(result) {
|
|
if (!result.inventory) return [];
|
|
return [
|
|
"- Inventory Roots: " + (result.inventory.roots.join(", ") || "(none)"),
|
|
"- Missing Roots Skipped: " + (result.inventory.missingRoots.join(", ") || "(none)"),
|
|
`- Files Scanned: ${result.inventory.filesScanned}`,
|
|
"- Inventory Counts:",
|
|
...Object.entries(result.inventory.counts).map(([id, count]) => ` - ${id}: ${count}`),
|
|
`- Total Inventory Matches: ${result.inventory.totalMatches}`,
|
|
];
|
|
}
|
|
|
|
export function renderReport(results) {
|
|
const lines = [
|
|
"# Readiness Docs Baseline Health",
|
|
"",
|
|
`Generated: ${new Date().toISOString()}`,
|
|
"",
|
|
"This report captures non-E2E baseline gates for Readiness Docs baseline. The runner exits non-zero when any shell gate fails; inventory gates remain read-only unless the runner itself fails.",
|
|
"The shell gates run with CI=1 and fingerprint TypeScript, ESLint, and focused Vitest smoke coverage. The Readiness Docs docs readiness inventory gate is read-only and fingerprints readiness, deployment, environment, feature/plugin, and risk-marker terms across the configured docs while reporting missing optional documentation roots as skipped.",
|
|
"",
|
|
];
|
|
for (const result of results) {
|
|
lines.push(
|
|
`## ${result.id}`,
|
|
"",
|
|
`- Command: \`${result.command}\``,
|
|
`- Exitcode: ${result.exitCode}`,
|
|
`- Failure Count: ${result.failureCount}`,
|
|
`- Duration: ${result.durationMs}ms`,
|
|
`- Output Fingerprint: ${result.fingerprint.hash}`,
|
|
...renderAuthorityGuard(result),
|
|
...renderDocTruthGuard(result),
|
|
...renderInventoryCounts(result),
|
|
"",
|
|
"```text",
|
|
result.fingerprint.excerpt,
|
|
"```",
|
|
""
|
|
);
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
async function writeFailureReport(error) {
|
|
await mkdir(path.dirname(reportPath), { recursive: true });
|
|
const output = error?.stack ?? String(error);
|
|
await writeFile(
|
|
reportPath,
|
|
renderReport([
|
|
{
|
|
id: "runner-error",
|
|
command: "node scripts/verify-readiness-docs-baseline.mjs",
|
|
exitCode: 1,
|
|
durationMs: 0,
|
|
failureCount: 1,
|
|
fingerprint: outputFingerprint(output),
|
|
},
|
|
]),
|
|
"utf8"
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const results = [];
|
|
for (const gate of gates) {
|
|
console.log(`Running ${gate.id}: ${shellCommand(gate)}`);
|
|
results.push(await runGate(gate));
|
|
}
|
|
await mkdir(path.dirname(reportPath), { recursive: true });
|
|
await writeFile(reportPath, renderReport(results), "utf8");
|
|
console.log(`Wrote ${path.relative(repoRoot, reportPath)}`);
|
|
const failedResults = results.filter((result) => result.exitCode !== 0 || result.failureCount > 0);
|
|
if (failedResults.length > 0) {
|
|
console.error(`Baseline gates failed: ${failedResults.map((result) => result.id).join(", ")}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main().catch(async (error) => {
|
|
console.error(error);
|
|
try {
|
|
await writeFailureReport(error);
|
|
} finally {
|
|
process.exit(1);
|
|
}
|
|
});
|
|
}
|