Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/scan-removed-modules.ts
T

323 lines
11 KiB
TypeScript

#!/usr/bin/env tsx
/* eslint-disable no-console */
import { readdir, readFile, stat } from "node:fs/promises";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const repoRoot = path.resolve(__dirname, "..");
export const removedModuleScanRoots = ["apps", "packages"] as const;
export const sourceFileExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".md", ".mdx"]);
export const ignoredDirectoryNames = new Set([
".cache",
".git",
".next",
".react-router",
".tc",
".turbo",
".vite",
"build",
"cache",
"coverage",
"dist",
"generated",
"node_modules",
"out",
"storybook-static",
"tmp",
"temp",
"vendor",
]);
const ignoredFileNames = new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]);
export const forbiddenRemovedModulePatterns = [
{ id: "postmark", pattern: /\bpostmark\b/i },
{ id: "rewardful", pattern: /\brewardful\b/i },
{ id: "pageblocks", pattern: /\bPageBlocks?\b/i },
{ id: "site-demo", pattern: /\b(?:Site-Demo|site demo|demo site|SiteDemo|my-site-demo)\b/i },
] as const;
const removedModulePrefilterPatterns = ["postmark", "rewardful", "PageBlock", "Site-Demo", "site demo", "demo site", "SiteDemo", "my-site-demo"] as const;
const maxReadConcurrency = 16;
const maxPrefilterArgBytes = 100_000;
export type RemovedModuleGroupId = (typeof forbiddenRemovedModulePatterns)[number]["id"];
export interface RemovedModuleDiagnostic {
id: RemovedModuleGroupId;
file: string;
line: number;
excerpt: string;
}
export interface RemovedModuleScanResult {
roots: string[];
missingRoots: string[];
files: string[];
diagnostics: RemovedModuleDiagnostic[];
}
export function normalizeRelativePath(relativePath: string) {
return relativePath.split(path.sep).join("/").replace(/^\.\//, "");
}
function toRelativePath(root: string, absolutePath: string) {
return normalizeRelativePath(path.relative(root, absolutePath));
}
export function isIgnoredDirectory(name: string, relativePath = name) {
const normalized = normalizeRelativePath(relativePath);
return ignoredDirectoryNames.has(name) || ignoredDirectoryNames.has(normalized) || name.endsWith(".cache") || normalized.includes("/__generated__/");
}
export function isGeneratedOrIgnoredFile(filePath: string) {
const name = path.basename(filePath);
return (
ignoredFileNames.has(name) ||
name.endsWith(".d.ts") ||
name.endsWith(".generated.ts") ||
name.endsWith(".generated.tsx") ||
name.endsWith(".gen.ts") ||
name.endsWith(".gen.tsx") ||
name.endsWith(".min.js") ||
name.endsWith(".map")
);
}
export function isTestStoryFixtureOrMockFile(filePath: string) {
const normalized = normalizeRelativePath(filePath);
const name = path.basename(normalized);
return (
/(?:^|[._-])(?:test|spec|stories|story|fixture|fixtures|mock|mocks)\.[cm]?[jt]sx?$/.test(name) ||
/\.(?:test|spec|stories|story|fixture|fixtures|mock|mocks)\.[cm]?[jt]sx?$/.test(name) ||
normalized.includes("/__tests__/") ||
normalized.includes("/test/") ||
normalized.includes("/tests/") ||
normalized.includes("/fixtures/") ||
normalized.includes("/fixture/") ||
normalized.includes("/mocks/") ||
normalized.includes("/__mocks__/")
);
}
export function isOptionalPluginOutput(relativePath: string) {
const normalized = normalizeRelativePath(relativePath);
return normalized === "packages/plugins" || normalized.startsWith("packages/plugins/");
}
export function isScannableSourceFile(filePath: string) {
return sourceFileExtensions.has(path.extname(filePath)) && !isGeneratedOrIgnoredFile(filePath);
}
async function pathExists(root: string, relativePath: string) {
try {
await stat(path.join(root, relativePath));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return false;
throw error;
}
}
export async function collectRemovedModuleSourceFiles(root = repoRoot, scanRoots: readonly string[] = removedModuleScanRoots) {
const roots: string[] = [];
const missingRoots: string[] = [];
const files: string[] = [];
async function walk(directory: string) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
const relativePath = toRelativePath(root, absolutePath);
if (entry.isDirectory()) {
if (isIgnoredDirectory(entry.name, relativePath) || isOptionalPluginOutput(relativePath)) continue;
await walk(absolutePath);
continue;
}
if (!entry.isFile()) continue;
if (!isScannableSourceFile(entry.name)) continue;
if (isOptionalPluginOutput(relativePath) || isTestStoryFixtureOrMockFile(relativePath)) continue;
files.push(absolutePath);
}
}
for (const scanRoot of scanRoots) {
const normalizedRoot = normalizeRelativePath(scanRoot);
if (!(await pathExists(root, normalizedRoot))) {
missingRoots.push(normalizedRoot);
continue;
}
roots.push(normalizedRoot);
const absoluteRoot = path.join(root, normalizedRoot);
const rootStat = await stat(absoluteRoot);
if (rootStat.isFile()) {
if (isScannableSourceFile(absoluteRoot) && !isTestStoryFixtureOrMockFile(normalizedRoot) && !isOptionalPluginOutput(normalizedRoot)) files.push(absoluteRoot);
continue;
}
await walk(absoluteRoot);
}
files.sort((a, b) => toRelativePath(root, a).localeCompare(toRelativePath(root, b)));
return { roots, missingRoots, files };
}
export function findRemovedModuleMatches(sourceText: string, relativeFilePath: string) {
const diagnostics: RemovedModuleDiagnostic[] = [];
const lines = sourceText.split(/\r?\n/);
lines.forEach((line, index) => {
for (const { id, pattern } of forbiddenRemovedModulePatterns) {
pattern.lastIndex = 0;
if (!pattern.test(line)) continue;
diagnostics.push({ id, file: normalizeRelativePath(relativeFilePath), line: index + 1, excerpt: line.trim() });
}
});
return diagnostics;
}
function chunkFilesForPrefilter(files: readonly string[]) {
const chunks: string[][] = [];
let chunk: string[] = [];
let chunkBytes = 0;
for (const file of files) {
const fileBytes = Buffer.byteLength(file) + 1;
if (chunk.length > 0 && chunkBytes + fileBytes > maxPrefilterArgBytes) {
chunks.push(chunk);
chunk = [];
chunkBytes = 0;
}
chunk.push(file);
chunkBytes += fileBytes;
}
if (chunk.length > 0) chunks.push(chunk);
return chunks;
}
async function runRipgrepPrefilter(root: string, files: readonly string[]) {
if (files.length === 0) return [];
const matchingFiles = new Set<string>();
const chunks = chunkFilesForPrefilter(files);
for (const chunk of chunks) {
const args = [
"--files-with-matches",
"--fixed-strings",
"--ignore-case",
"--null",
"--no-ignore",
"--hidden",
"--text",
"--no-messages",
...removedModulePrefilterPatterns.flatMap((pattern) => ["--regexp", pattern]),
"--",
...chunk,
];
const { code, output } = await new Promise<{ code: number | null; output: string }>((resolve, reject) => {
const child = spawn("rg", args, { cwd: root });
const outputChunks: Buffer[] = [];
const errorChunks: Buffer[] = [];
child.stdout.on("data", (data: Buffer) => outputChunks.push(data));
child.stderr.on("data", (data: Buffer) => errorChunks.push(data));
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0 && code !== 1) {
const details = Buffer.concat(errorChunks).toString("utf8").trim();
reject(new Error(`removed-module prefilter failed with exit code ${code}${details ? `: ${details}` : ""}`));
return;
}
resolve({ code, output: Buffer.concat(outputChunks).toString("utf8") });
});
});
if (code === 1 || output.length === 0) continue;
for (const file of output.split("\0")) {
if (file.length === 0) continue;
matchingFiles.add(normalizeRelativePath(path.isAbsolute(file) ? path.relative(root, file) : file));
}
}
return files.filter((file) => matchingFiles.has(toRelativePath(root, file)));
}
export async function prefilterRemovedModuleCandidateFiles(root: string, files: readonly string[]) {
try {
return await runRipgrepPrefilter(root, files);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return [...files];
throw error;
}
}
async function mapWithConcurrency<T, R>(items: readonly T[], limit: number, mapper: (item: T, index: number) => Promise<R>) {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index], index);
}
});
await Promise.all(workers);
return results;
}
export async function scanRemovedModules(root = repoRoot, scanRoots: readonly string[] = removedModuleScanRoots): Promise<RemovedModuleScanResult> {
const collection = await collectRemovedModuleSourceFiles(root, scanRoots);
const candidateFiles = await prefilterRemovedModuleCandidateFiles(root, collection.files);
const diagnosticsByFile = await mapWithConcurrency(candidateFiles, maxReadConcurrency, async (file) => {
const relativeFilePath = toRelativePath(root, file);
const sourceText = await readFile(file, "utf8");
return findRemovedModuleMatches(sourceText, relativeFilePath);
});
const diagnostics = diagnosticsByFile.flat();
return { roots: collection.roots, missingRoots: collection.missingRoots, files: collection.files.map((file) => toRelativePath(root, file)), diagnostics };
}
export function renderRemovedModuleScanResult(result: RemovedModuleScanResult) {
const lines = [
`removed-module scan roots: ${result.roots.join(", ") || "(none)"}`,
`removed-module missing roots skipped: ${result.missingRoots.join(", ") || "(none)"}`,
`files scanned: ${result.files.length}`,
`matches found: ${result.diagnostics.length}`,
];
if (result.diagnostics.length === 0) {
lines.unshift("PASS removed-module scan: no forbidden runtime references found");
return lines.join("\n");
}
lines.unshift("FAIL removed-module scan: forbidden runtime references found");
for (const diagnostic of result.diagnostics) {
lines.push(`${diagnostic.file}:${diagnostic.line}: [${diagnostic.id}] ${diagnostic.excerpt}`);
}
return lines.join("\n");
}
async function main() {
const result = await scanRemovedModules(repoRoot);
const output = renderRemovedModuleScanResult(result);
if (result.diagnostics.length > 0) {
console.error(output);
process.exitCode = 1;
return;
}
console.log(output);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}