diff --git a/package.json b/package.json index d86db42..8779af5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format:check": "prettier \"**/*.{ts,tsx,js,jsx,mjs,cjs,json,css,yml,yaml}\" --check", "check": "pnpm lint && pnpm typecheck && pnpm format:check", "typecheck": "tsc --noEmit", + "guard:no-mock": "node scripts/guard-no-mock.mjs", "test": "vitest run", "test:watch": "vitest" }, diff --git a/scripts/guard-no-mock.mjs b/scripts/guard-no-mock.mjs new file mode 100644 index 0000000..2df0af7 --- /dev/null +++ b/scripts/guard-no-mock.mjs @@ -0,0 +1,156 @@ +import fs from "node:fs/promises" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + +const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]) +const IGNORE_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "coverage"]) + +function rel(p) { + return path.relative(REPO_ROOT, p) +} + +async function statSafe(p) { + try { + return await fs.stat(p) + } catch { + return null + } +} + +async function listFilesRecursive(dir, { exts = null } = {}) { + const out = [] + const st = await statSafe(dir) + if (!st || !st.isDirectory()) return out + + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const ent of entries) { + if (ent.isDirectory()) { + if (IGNORE_DIRS.has(ent.name)) continue + out.push(...(await listFilesRecursive(path.join(dir, ent.name), { exts }))) + continue + } + if (!ent.isFile()) continue + const fullPath = path.join(dir, ent.name) + if (exts && !exts.has(path.extname(ent.name))) continue + out.push(fullPath) + } + return out +} + +async function findFilesContainingString(roots, needle) { + const matches = [] + for (const root of roots) { + const absRoot = path.join(REPO_ROOT, root) + const files = await listFilesRecursive(absRoot, { exts: SOURCE_EXTS }) + for (const file of files) { + const content = await fs.readFile(file, "utf8") + if (content.includes(needle)) matches.push(file) + } + } + return matches +} + +async function findFilesMatchingPredicate(roots, predicate) { + const matches = [] + for (const root of roots) { + const absRoot = path.join(REPO_ROOT, root) + const files = await listFilesRecursive(absRoot, { exts: SOURCE_EXTS }) + for (const file of files) { + const content = await fs.readFile(file, "utf8") + if (predicate(content)) matches.push(file) + } + } + return matches +} + +async function findAppApiRoutes() { + const apiRoot = path.join(REPO_ROOT, "app", "api") + const st = await statSafe(apiRoot) + if (!st || !st.isDirectory()) return [] + + const files = await listFilesRecursive(apiRoot, { exts: new Set([".ts"]) }) + return files.filter((p) => path.basename(p) === "route.ts") +} + +async function main() { + const violations = [] + + const mockImports = await findFilesContainingString( + ["app", "components", "lib", "store", "tests"], + "@/lib/mock", + ) + if (mockImports.length > 0) { + violations.push({ + title: "Found @/lib/mock import string in source files", + files: mockImports, + }) + } + + const libMockDir = path.join(REPO_ROOT, "lib", "mock") + const libMockSt = await statSafe(libMockDir) + if (libMockSt?.isDirectory()) { + violations.push({ + title: "Found lib/mock directory", + files: [libMockDir], + }) + } + + const apiRoutes = await findAppApiRoutes() + if (apiRoutes.length > 0) { + violations.push({ + title: "Found app/api/**/route.ts files", + files: apiRoutes, + }) + } + + const absoluteBackendMatches = await findFilesMatchingPredicate( + ["app", "components"], + (content) => + content.includes("NEXT_PUBLIC_BACKEND_URL") || /https?:\/\/localhost:8080/.test(content), + ) + if (absoluteBackendMatches.length > 0) { + violations.push({ + title: + "Found absolute backend origins or NEXT_PUBLIC_BACKEND_URL usage in client code (app/ or components/)", + files: absoluteBackendMatches, + }) + } + + const apiDir = path.join(REPO_ROOT, "lib", "api") + const apiFiles = (await listFilesRecursive(apiDir, { exts: new Set([".ts"]) })).filter( + (p) => p !== path.join(apiDir, "client.ts"), + ) + const storeImportRe = /^\s*(?:import|export)\s+(?:type\s+)?(?:[\w*\s{},]*from\s*)?['"]@\/store\//m + const apiStoreImports = [] + for (const file of apiFiles) { + const content = await fs.readFile(file, "utf8") + if (storeImportRe.test(content)) apiStoreImports.push(file) + } + if (apiStoreImports.length > 0) { + violations.push({ + title: "Found @/store/ imports in lib/api (except lib/api/client.ts)", + files: apiStoreImports, + }) + } + + if (violations.length > 0) { + console.error("guard:no-mock failed") + for (const v of violations) { + console.error(`\n- ${v.title}`) + for (const f of v.files) console.error(` - ${rel(f)}`) + } + process.exitCode = 1 + return + } + + console.log("guard:no-mock ok") +} + +main().catch((err) => { + console.error("guard:no-mock crashed") + console.error(err) + process.exit(1) +})