#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const EXPECTED_SCHEMA_OUTPUT = "../../packages/db/generated/prisma/client"; export const PACKAGE_GENERATED_ENTRYPOINT_PATTERN = /(?:from\s+["']|export\s+\*\s+from\s+["'])\.\.\/generated\/prisma\/client\/(?:client|browser)\.(?:ts|js)["'];?/; const DEFAULT_IGNORED_DIRECTORIES = new Set([ ".git", ".tc", ".turbo", ".next", ".react-router", ".vite", "build", "coverage", "dist", "generated", "node_modules", "vendor", ]); const DEFAULT_TEXT_EXTENSIONS = new Set([ ".cjs", ".css", ".js", ".json", ".jsx", ".md", ".mdx", ".mjs", ".prisma", ".ts", ".tsx", ".txt", ".yaml", ".yml", ]); const FORBIDDEN_TEXT_PATTERNS = [ { label: "root/app Prisma generated-client path", pattern: /app\/generated\/prisma/g, }, { label: "root-local generated-client import", pattern: /(?:from|import)\s*\(?\s*["'][^"']*(?:^|\/)app\/generated\/prisma[^"']*["']/g, }, { label: "app-local generated Prisma client import", pattern: /(?:from|import)\s*\(?\s*["'](?:~\/generated\/prisma\/client\/(?:browser|client)|(?:~\/)?generated\/prisma\/client\/(?:browser|client))["']/g, }, ]; function normalizeRelativePath(relativePath) { return relativePath.split(path.sep).join("/"); } function isIgnoredDirectory(name) { return DEFAULT_IGNORED_DIRECTORIES.has(name) || name.endsWith(".cache") || name === "cache"; } function isScannableTextFile(filePath) { return DEFAULT_TEXT_EXTENSIONS.has(path.extname(filePath)); } function readTextFile(rootDir, relativePath) { return fs.readFileSync(path.join(rootDir, relativePath), "utf8"); } export function listRepositoryTextFiles(rootDir, directory = ".") { const absoluteDirectory = path.join(rootDir, directory); const entries = fs.readdirSync(absoluteDirectory, { withFileTypes: true }); const files = []; for (const entry of entries) { const relativePath = path.join(directory, entry.name); if (entry.isDirectory()) { if (!isIgnoredDirectory(entry.name)) { files.push(...listRepositoryTextFiles(rootDir, relativePath)); } continue; } if (entry.isFile() && isScannableTextFile(entry.name)) { files.push(normalizeRelativePath(relativePath === "." ? entry.name : relativePath)); } } return files.sort(); } export function checkSchemaOutput(rootDir) { const schemaPath = "prisma/schema/schema.prisma"; const failures = []; const passes = []; if (!fs.existsSync(path.join(rootDir, schemaPath))) { return { passes, failures: [`missing Prisma schema: ${schemaPath}`] }; } const schema = readTextFile(rootDir, schemaPath); const outputMatch = schema.match(/^\s*output\s*=\s*"([^"]+)"\s*$/m); if (!outputMatch) { failures.push(`${schemaPath}: generator client must declare output = "${EXPECTED_SCHEMA_OUTPUT}"`); } else if (outputMatch[1] !== EXPECTED_SCHEMA_OUTPUT) { failures.push(`${schemaPath}: expected generator output "${EXPECTED_SCHEMA_OUTPUT}", got "${outputMatch[1]}"`); } else { passes.push(`${schemaPath}: generator output is package-owned`); } if (/\.\.\/\.\.\/app\/generated\/prisma|app\/generated\/prisma/.test(schema)) { failures.push(`${schemaPath}: must not reference old root/app generated Prisma ownership`); } return { passes, failures }; } export function checkPackageBoundaryImports(rootDir) { const boundaryFiles = [ "packages/db/src/client.server.ts", "packages/db/src/prisma-browser.ts", ]; const passes = []; const failures = []; for (const relativePath of boundaryFiles) { const absolutePath = path.join(rootDir, relativePath); if (!fs.existsSync(absolutePath)) { failures.push(`missing package boundary file: ${relativePath}`); continue; } const source = fs.readFileSync(absolutePath, "utf8"); if (PACKAGE_GENERATED_ENTRYPOINT_PATTERN.test(source)) { passes.push(`${relativePath}: uses package-local Prisma 7 TypeScript generated entrypoint`); } else { failures.push(`${relativePath}: must import/export ../generated/prisma/client/client.ts or ../generated/prisma/client/browser.ts`); } if (/app\/generated\/prisma|\.\.\/\.\.\/.*generated\/prisma/.test(source)) { failures.push(`${relativePath}: must not cross out to root/app generated Prisma ownership`); } } return { passes, failures }; } export function scanForbiddenPrismaOwnershipText(rootDir) { const passes = []; const failures = []; const files = listRepositoryTextFiles(rootDir); for (const relativePath of files) { if ( relativePath === "scripts/verify-prisma-ownership.mjs" || relativePath === "scripts/verify-prisma-ownership.test.mjs" || relativePath === "scripts/verify-db-boundary-eslint.test.mjs" ) { continue; } const source = readTextFile(rootDir, relativePath); for (const { label, pattern } of FORBIDDEN_TEXT_PATTERNS) { pattern.lastIndex = 0; if (pattern.test(source)) { failures.push(`${relativePath}: forbidden ${label}`); } } } if (failures.length === 0) { passes.push("repository text scan: no forbidden root/app Prisma generated-client ownership references found"); } return { passes, failures }; } export function verifyPrismaOwnership(rootDir = process.cwd()) { const checks = [ checkSchemaOutput(rootDir), checkPackageBoundaryImports(rootDir), scanForbiddenPrismaOwnershipText(rootDir), ]; return checks.reduce( (result, check) => ({ passes: [...result.passes, ...check.passes], failures: [...result.failures, ...check.failures], }), { passes: [], failures: [] }, ); } export function formatVerificationResult(result) { if (result.failures.length > 0) { return [ "FAIL Prisma ownership verification", ...result.failures.map((failure) => `- ${failure}`), `Summary: ${result.passes.length} passed, ${result.failures.length} failed`, "", ].join("\n"); } return [ "PASS Prisma ownership verification", ...result.passes.map((pass) => `- ${pass}`), `Summary: ${result.passes.length} passed, 0 failed`, "", ].join("\n"); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { const result = verifyPrismaOwnership(process.cwd()); const output = formatVerificationResult(result); if (result.failures.length > 0) { process.stderr.write(output); process.exitCode = 1; } else { process.stdout.write(output); } }