#!/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(); 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(items: readonly T[], limit: number, mapper: (item: T, index: number) => Promise) { const results = new Array(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 { 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; }); }