Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,322 @@
|
||||
#!/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/release-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 releaseDocsReleaseDocumentationInventoryScanRoots = [
|
||||
"CHANGELOG.md",
|
||||
"docs/versioning.md",
|
||||
"docs/upgrade.md",
|
||||
"docs/releases",
|
||||
"docs/migration-registry.md",
|
||||
"docs/release-checklist.md",
|
||||
"docs/boilerplate-version-tracking.md",
|
||||
"docs/production-checklist.md",
|
||||
"docs/upgrades.md",
|
||||
"README.md",
|
||||
];
|
||||
|
||||
export const releaseDocsReleaseDocumentationInventoryPatterns = [
|
||||
{ id: "semver-versioning", pattern: /\b(?:semver|semantic version(?:ing)?|major|minor|patch|version(?:ing)?)\b/gi },
|
||||
{ id: "breaking-change-policy", pattern: /\b(?:breaking change|breaking-change|deprecat(?:e|ed|ion)|compatibility|backward compatible)\b/gi },
|
||||
{ id: "changelog", pattern: /\b(?:changelog|change log|Keep a Changelog|added|changed|deprecated|removed|fixed|security)\b/gi },
|
||||
{ id: "release-checklist", pattern: /\b(?:release checklist|release check|pre-release|post-release|cut release|tag|artifact)\b/gi },
|
||||
{ id: "upgrade-guide", pattern: /\b(?:upgrade guide|upgrade|migration guide|migrat(?:e|ion|ions)|rollout|rollback)\b/gi },
|
||||
{ id: "migration-registry", pattern: /\b(?:migration registry|migration id|migration entry|owner|status|deadline)\b/gi },
|
||||
{ id: "boilerplate-version-tracking", pattern: /\b(?:boilerplate version|template version|source version|ported project|downstream project|baseline version)\b/gi },
|
||||
{ id: "production-readiness", pattern: /\b(?:production checklist|production readiness|deploy(?:ment)?|CI|non-E2E|verification|manual runtime)\b/gi },
|
||||
{ id: "docs-link-command", pattern: /(?:https?:\/\/[^\s)>\'"]+|\[[^\]]+\]\([^\)]+\)|`[^`]*(?:pnpm|npm|node|tsx|npx|yarn)[^`]*`)/gi },
|
||||
{ id: "todo-tbd-marker", pattern: /\b(?:TODO|TBD|FIXME|XXX|follow-up|Follow-up)\b/g },
|
||||
];
|
||||
|
||||
export const gates = [
|
||||
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
|
||||
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
|
||||
{
|
||||
id: "vitest-releaseDocs-focused",
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
"scripts/verify-release-docs-baseline.test.mjs",
|
||||
"scripts/verify-ci-baseline.test.mjs",
|
||||
"scripts/verify-production-docs.test.mjs",
|
||||
],
|
||||
},
|
||||
{ id: "releaseDocs-release-docs-inventory", type: "releaseDocs-release-docs-inventory" },
|
||||
];
|
||||
|
||||
function shellCommand(gate) {
|
||||
if (gate.type === "releaseDocs-release-docs-inventory") return "read-only Release Docs release documentation inventory";
|
||||
return [gate.command, ...gate.args].join(" ");
|
||||
}
|
||||
|
||||
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 === "releaseDocs-release-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-releaseDocs-focused") 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 runReleaseDocsReleaseDocumentationInventory(root = repoRoot) {
|
||||
const existingRoots = [];
|
||||
const missingRoots = [];
|
||||
for (const rootRelativePath of releaseDocsReleaseDocumentationInventoryScanRoots) {
|
||||
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(releaseDocsReleaseDocumentationInventoryPatterns.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 releaseDocsReleaseDocumentationInventoryPatterns) {
|
||||
pattern.lastIndex = 0;
|
||||
const found = line.match(pattern);
|
||||
if (!found) continue;
|
||||
counts.set(id, (counts.get(id) ?? 0) + found.length);
|
||||
for (const match of found) {
|
||||
matches.push(`${relativeFile}:${index + 1}: [${id}] ${match}: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return { roots: existingRoots, missingRoots, filesScanned: files.length, counts: Object.fromEntries(counts.entries()), totalMatches: matches.length, matches };
|
||||
}
|
||||
|
||||
async function runReleaseDocsReleaseDocumentationInventoryGate(gate) {
|
||||
const startedAt = Date.now();
|
||||
const inventory = await runReleaseDocsReleaseDocumentationInventory(repoRoot);
|
||||
const outputLines = [
|
||||
`Release Docs release documentation inventory scanned roots: ${inventory.roots.join(", ") || "(none)"}`,
|
||||
`Release Docs release documentation 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),
|
||||
];
|
||||
const failures = [];
|
||||
if (inventory.missingRoots.length > 0) {
|
||||
failures.push(`missing required release documentation roots: ${inventory.missingRoots.join(", ")}`);
|
||||
}
|
||||
if (inventory.filesScanned === 0) {
|
||||
failures.push("release documentation inventory scanned 0 files");
|
||||
}
|
||||
outputLines.push(...failures.map((failure) => `FAIL ${failure}`));
|
||||
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: failures.length > 0 ? 1 : 0, durationMs: Date.now() - startedAt, failureCount: failures.length, inventory, fingerprint: outputFingerprint(output) };
|
||||
}
|
||||
|
||||
async function runGate(gate) {
|
||||
if (gate.type === "releaseDocs-release-docs-inventory") return runReleaseDocsReleaseDocumentationInventoryGate(gate);
|
||||
return runShellGate(gate);
|
||||
}
|
||||
|
||||
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 = [
|
||||
"# Release Docs Baseline Health",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
"",
|
||||
"This report captures non-E2E baseline gates for Release 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 Release Docs release documentation inventory gate is read-only and fingerprints release-contract readiness terms for semver/versioning, breaking-change policy, changelog, release checklist, upgrade guide, migration registry, boilerplate version tracking, production readiness, docs links/commands, and TODO/TBD markers across the configured docs while reporting missing future 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}`,
|
||||
...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-release-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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user