203 lines
10 KiB
JavaScript
203 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/* eslint-disable no-console */
|
|
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";
|
|
import { markFailedBaselineGates } from "./baseline-gate-exit.mjs";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
export const reportPath = path.join(repoRoot, ".tc/baselines/export-boundary/baseline-health.md");
|
|
|
|
const ignoredDirectoryNames = new Set([".git", "node_modules", "build", "dist", "coverage", ".react-router", ".turbo", ".next", ".cache", ".vite", "generated"]);
|
|
|
|
export const exportInventoryScanRoots = ["apps/web/app/routes", "apps/api/src", "apps/worker/src", "packages/db/src", "packages/queue/src", "packages/storage/src", "prisma/schema"];
|
|
|
|
export const exportInventoryPatterns = [
|
|
{ id: "export-route-reference", pattern: /\b(?:export|exports|download|downloads|import-export|bulk-export|route\(|loader\(|action\()\b/gi },
|
|
{ id: "export-format-term", pattern: /\b(?:csv|json|xlsx|excel|spreadsheet|pdf|application\/pdf|text\/csv|application\/json)\b/gi },
|
|
{ id: "queue-worker-export-wiring", pattern: /\b(?:queue|job|worker|producer|consumer|enqueue|processExport|exportJob|EXPORT_)\b/g },
|
|
{ id: "signed-download-storage-reference", pattern: /\b(?:signed|signedUrl|createSignedUrl|getSignedUrl|downloadUrl|storage|bucket|objectKey|FileObject)\b/g },
|
|
{ id: "retention-expiry-marker", pattern: /\b(?:retention|expiresAt|expire|expiry|ttl|timeToLive|deleteAfter|cleanup|purge)\b/gi },
|
|
{ id: "permission-audit-marker", pattern: /\b(?:permission|permissions|authorize|authorized|audit|auditable|actorId|tenantId|workspaceId)\b/g },
|
|
{ id: "generated-client-reference", pattern: /\b(?:packages\/db\/generated|@toolctrl\/db\/generated|generated\/prisma|PrismaClient|client\/models\/FileObject)\b/g },
|
|
];
|
|
|
|
export const gates = [
|
|
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
|
|
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
|
|
{ id: "vitest-export-boundary", command: "pnpm", args: ["exec", "vitest", "run", "packages/db/src/file-object-schema.test.ts", "packages/storage/src/index.test.ts", "packages/storage/src/signed/index.test.ts"] },
|
|
{ id: "vitest-export-infrastructure", command: "pnpm", args: ["exec", "vitest", "run", "packages/queue/src/producers.test.ts", "apps/worker/src/handlers.test.ts", "apps/worker/src/runtime.test.ts"] },
|
|
{ id: "export-inventory", type: "export-inventory" },
|
|
];
|
|
|
|
function shellCommand(gate) {
|
|
if (gate.type === "export-inventory") return "read-only export 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 === "export-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-export-boundary" || gateId === "vitest-export-infrastructure") 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) || name.endsWith(".cache") || relativePath === "packages/db/generated" || relativePath.startsWith("packages/db/generated/");
|
|
}
|
|
|
|
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 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() && /\.(?:ts|tsx|js|jsx|mjs|cjs|prisma|sql)$/.test(entry.name) && !entry.name.endsWith(".generated.ts") && !entry.name.endsWith(".generated.tsx")) {
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
}
|
|
await walk(absoluteRoot);
|
|
return files;
|
|
}
|
|
|
|
export async function runExportInventory(root = repoRoot) {
|
|
const existingRoots = [];
|
|
for (const rootRelativePath of exportInventoryScanRoots) if (await rootExists(root, rootRelativePath)) existingRoots.push(rootRelativePath);
|
|
const files = (await Promise.all(existingRoots.map((rootRelativePath) => collectFiles(root, rootRelativePath)))).flat();
|
|
const counts = new Map(exportInventoryPatterns.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 exportInventoryPatterns) {
|
|
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, counts: Object.fromEntries(counts.entries()), totalMatches: matches.length, matches };
|
|
}
|
|
|
|
async function runExportInventoryGate(gate) {
|
|
const startedAt = Date.now();
|
|
const inventory = await runExportInventory(repoRoot);
|
|
const outputLines = [`Export inventory scanned roots: ${inventory.roots.join(", ")}`, ...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, fingerprint: outputFingerprint(output) };
|
|
}
|
|
|
|
async function runGate(gate) {
|
|
if (gate.type === "export-inventory") return runExportInventoryGate(gate);
|
|
return runShellGate(gate);
|
|
}
|
|
|
|
export function renderReport(results) {
|
|
const lines = [
|
|
"# Export Boundary Baseline Health",
|
|
"",
|
|
`Generated: ${new Date().toISOString()}`,
|
|
"",
|
|
"This report captures non-E2E baseline gates for Export Boundary baseline. The runner exits 0 when every gate was executed and this report was written, even if individual gates fail.",
|
|
"The export inventory gate is read-only and fingerprints export routes, CSV/JSON/XLSX/PDF format terms, queue/worker export wiring, signed download/storage references, retention and expiry markers, permission/audit markers, and accidental generated Prisma client references while skipping generated folders such as packages/db/generated.",
|
|
"",
|
|
];
|
|
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}`, "", "```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-export-boundary-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)}`);
|
|
markFailedBaselineGates(results);
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main().catch(async (error) => {
|
|
console.error(error);
|
|
try {
|
|
await writeFailureReport(error);
|
|
} finally {
|
|
process.exit(1);
|
|
}
|
|
});
|
|
}
|