323 lines
11 KiB
TypeScript
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;
|
|
});
|
|
}
|