#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; const root = path.resolve(process.argv[2] ?? process.cwd()); const failures = []; const passes = []; const removedBlogApiPath = path.join("apps", "web", "app", "utils", "api", ".server", "BlogApi.ts"); const blogModulePath = path.join("apps", "web", "app", "modules", "blog"); const inventoryPath = path.join("docs", "feature-inventory.md"); const removeCandidatesPath = path.join("docs", "feature-manifests", "remove-candidates.json"); const appPath = path.join("apps", "web", "app"); const runtimeExtensions = new Set([".ts", ".tsx"]); const allowedBlogModuleRuntimeBasenames = new Set(["README.ts", "README.tsx"]); function abs(relativePath) { return path.join(root, relativePath); } function pass(message) { passes.push(message); } function fail(message) { failures.push(message); } function relativeFromRoot(filePath) { return path.relative(root, filePath).split(path.sep).join("/"); } function exists(relativePath) { return fs.existsSync(abs(relativePath)); } function readText(relativePath) { try { return fs.readFileSync(abs(relativePath), "utf8"); } catch { fail(`required file is missing or unreadable: ${relativePath}`); return ""; } } function walkFiles(relativePath) { const start = abs(relativePath); if (!fs.existsSync(start)) return []; const files = []; const stack = [start]; while (stack.length > 0) { const current = stack.pop(); let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (entry.name === "node_modules" || entry.name === ".git") continue; const entryPath = path.join(current, entry.name); if (entry.isDirectory()) stack.push(entryPath); else if (entry.isFile()) files.push(entryPath); } } return files.sort((a, b) => relativeFromRoot(a).localeCompare(relativeFromRoot(b))); } function appRuntimeFiles() { return walkFiles(appPath).filter((filePath) => runtimeExtensions.has(path.extname(filePath))); } function lineNumberFor(text, index) { return text.slice(0, index).split(/\r?\n/).length; } function checkRemovedBlogApiFile() { if (exists(removedBlogApiPath)) fail(`removed Blog API file still exists: ${removedBlogApiPath}`); else pass(`removed Blog API file is absent: ${removedBlogApiPath}`); } function checkAppReferences() { const offenders = []; for (const filePath of appRuntimeFiles()) { const relativePath = relativeFromRoot(filePath); let text; try { text = fs.readFileSync(filePath, "utf8"); } catch { fail(`could not read source file while scanning app references: ${relativePath}`); continue; } for (const pattern of ["~/modules/blog", "BlogApi"]) { const index = text.indexOf(pattern); if (index !== -1) offenders.push(`${relativePath}:${lineNumberFor(text, index)} references ${pattern}`); } } if (offenders.length > 0) { for (const offender of offenders) fail(`stale Blog source reference: ${offender}`); } else { pass("app runtime sources contain no ~/modules/blog imports or BlogApi references"); } } function checkBlogModuleHasNoRuntimeFiles() { const runtimeFiles = walkFiles(blogModulePath) .filter((filePath) => runtimeExtensions.has(path.extname(filePath))) .map(relativeFromRoot) .filter((relativePath) => !allowedBlogModuleRuntimeBasenames.has(path.basename(relativePath))); if (runtimeFiles.length > 0) { for (const runtimeFile of runtimeFiles) fail(`blog module runtime file remains: ${runtimeFile}`); } else { pass("apps/web/app/modules/blog contains no runtime .ts/.tsx files"); } } function checkDocNote(relativePath, label) { const text = readText(relativePath); if (!text) return; const requiredTokens = ["Feature Decision/S06", "BlogApi", "remove-from-core"]; const missing = requiredTokens.filter((token) => !text.includes(token)); if (missing.length === 0) pass(`${label} records the Feature Decision/S06 BlogApi remove-from-core note`); else fail(`${label} must record an Feature Decision/S06 BlogApi remove-from-core note; missing: ${missing.join(", ")}`); } checkRemovedBlogApiFile(); checkAppReferences(); checkBlogModuleHasNoRuntimeFiles(); checkDocNote(inventoryPath, "feature inventory"); checkDocNote(removeCandidatesPath, "remove candidates manifest"); if (failures.length > 0) { process.stderr.write(["FAIL first approved small feature cut verification", ...failures.map((failure) => `- ${failure}`), `Summary: ${passes.length} passed, ${failures.length} failed`].join("\n") + "\n"); process.exit(1); } process.stdout.write(`PASS first approved small feature cut verification\nSummary: ${passes.length} passed, 0 failed\n`);