228 lines
6.5 KiB
JavaScript
228 lines
6.5 KiB
JavaScript
#!/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);
|
|
}
|
|
}
|