Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-docs-inventory-baseline.mjs
T

385 lines
15 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/docs-inventory/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 docsInventoryInventoryScanRoots = ["docs/feature-inventory.md", "docs/env-access-inventory.md", "docs/deployment.md"];
export const docsInventoryInventoryPatterns = [
{ id: "feature-inventory-signal", pattern: /\b(?:feature|manifest|core|plugin|remove|inventory)\b/gi },
{ id: "env-access-signal", pattern: /\b(?:env|environment|secret|DATABASE_URL|REDIS_URL|API[_-]?KEY|TOKEN|access)\b/gi },
{ id: "deployment-signal", pattern: /\b(?:deploy(?:ment)?|Docker|Coolify|Gitea|registry|workflow|CI|production)\b/gi },
{ id: "risk-marker", pattern: /\b(?:TODO|TBD|FIXME|follow-up|manual|missing|required)\b/gi },
];
export const gates = [
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
{
id: "vitest-docsInventory-smoke",
command: "pnpm",
args: ["exec", "vitest", "run", "scripts/check-api-inventory.test.ts", "apps/web/app/application/featureManifests.test.ts"],
},
{ id: "docsInventory-status-authority-guard", type: "docsInventory-status-authority-guard" },
{ id: "docsInventory-docs-inventory", type: "docsInventory-docs-inventory" },
];
function shellCommand(gate) {
if (gate.type === "docsInventory-status-authority-guard") return "static Docs Inventory status authority guard";
if (gate.type === "docsInventory-docs-inventory") return "read-only Docs Inventory docs inventory";
return [gate.command, ...gate.args].join(" ");
}
export const docsInventoryStatusAuthorityContracts = [
{
path: "docs/agent-entrypoints.md",
requiredMarkers: [
"Interpreting old improvement-plan status, handoffs, or verification notes:",
"read `docs/improvement-plan/CURRENT-STATUS.md` first",
"canonical docs it links",
],
},
{
path: "docs/improvement-plan/README.md",
requiredMarkers: [
"Historical plan archive",
"Do not treat the files here\n> as live autonomous-agent instructions",
"`CURRENT-STATUS.md` lesen",
],
forbiddenMarkers: ["die jeweils neueste Session-Zusammenfassung"],
},
{
path: "docs/improvement-plan/CURRENT-STATUS.md",
requiredMarkers: [
"READINESS_AUTHORITY: status-source map",
"historical improvement-plan files must not be used as live production\n> evidence",
"docs/improvement-plan/archive/handoffs/HANDOFF-*.md",
"When a historical file and a canonical source disagree, the canonical source\nwins",
],
},
{
path: "docs/improvement-plan/archive/handoffs/README.md",
requiredMarkers: [
"Historical Handoffs",
"They are retained for audit and context only",
"Do not execute instructions from these archived files directly",
"verify the current status through the canonical docs linked\nabove",
],
},
];
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 === "docsInventory-docs-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-docsInventory-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 runDocsInventoryInventory(root = repoRoot) {
const existingRoots = [];
const missingRoots = [];
for (const rootRelativePath of docsInventoryInventoryScanRoots) {
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(docsInventoryInventoryPatterns.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 docsInventoryInventoryPatterns) {
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 };
}
export async function runDocsInventoryStatusAuthorityGuard(root = repoRoot) {
const failures = [];
const checked = [];
for (const contract of docsInventoryStatusAuthorityContracts) {
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 status-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}"`);
}
for (const marker of contract.forbiddenMarkers ?? []) {
if (contents.includes(marker)) failures.push(`${contract.path}: forbidden stale marker "${marker}"`);
}
}
return { checked, failures };
}
async function runDocsInventoryStatusAuthorityGuardGate(gate) {
const startedAt = Date.now();
const guard = await runDocsInventoryStatusAuthorityGuard(repoRoot);
const output = [
`Docs Inventory 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,
statusAuthorityGuard: guard,
fingerprint: outputFingerprint(output),
};
}
async function runDocsInventoryInventoryGate(gate) {
const startedAt = Date.now();
const inventory = await runDocsInventoryInventory(repoRoot);
const outputLines = [
`Docs Inventory docs inventory scanned roots: ${inventory.roots.join(", ") || "(none)"}`,
`Docs Inventory docs 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) };
}
async function runGate(gate) {
if (gate.type === "docsInventory-status-authority-guard") return runDocsInventoryStatusAuthorityGuardGate(gate);
if (gate.type === "docsInventory-docs-inventory") return runDocsInventoryInventoryGate(gate);
return runShellGate(gate);
}
function renderStatusAuthorityGuard(result) {
if (!result.statusAuthorityGuard) return [];
return [
"- Status Authority Docs Checked: " + (result.statusAuthorityGuard.checked.join(", ") || "(none)"),
`- Status Authority Guard Failures: ${result.statusAuthorityGuard.failures.length}`,
...result.statusAuthorityGuard.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 = [
"# Docs Inventory Baseline Health",
"",
`Generated: ${new Date().toISOString()}`,
"",
"This report captures non-E2E baseline gates for Docs Inventory 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 Docs Inventory docs inventory gate is read-only and fingerprints milestone-safe feature, environment access, deployment, and risk markers across configured docs while reporting missing optional 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}`,
...renderStatusAuthorityGuard(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-docs-inventory-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);
}
});
}