146 lines
4.7 KiB
JavaScript
146 lines
4.7 KiB
JavaScript
#!/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`);
|