Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/verify-cross-cutting-models-complete.mjs
T

83 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
/**
* CI tenant-boundary drift gate.
*
* The explicit manifest classifies every Prisma model, including nullable
* hybrids, FK-derived children, global catalogs, platform-admin data, and the
* Tenant root. The legacy lifecycle registry remains checked separately because
* snapshot/restore/delete currently operates on direct `tenantId` fields only.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { TENANT_SCOPE_MANIFEST } from "../prisma/tenant-scope-manifest.mjs";
import { parsePrismaSchemaDirectory, validateTenantScopeManifest } from "./lib/prisma-tenant-scope.mjs";
function listRegisteredLifecycleModels(modelsFile) {
const text = readFileSync(modelsFile, "utf8");
const start = text.indexOf("export const TENANT_SCOPED_MODELS");
if (start === -1) throw new Error(`could not find TENANT_SCOPED_MODELS in ${modelsFile}`);
const end = text.indexOf("];", start);
if (end === -1) throw new Error(`could not find end of TENANT_SCOPED_MODELS array in ${modelsFile}`);
const names = new Set();
for (const match of text.slice(start, end).matchAll(/model:\s*"([^"]+)"/g)) names.add(match[1]);
return names;
}
export function inspectCrossCuttingModels(rootDirectory = process.cwd(), options = {}) {
const schemaDirectory = path.join(rootDirectory, "prisma", "schema");
const modelsFile = path.join(rootDirectory, "packages", "cli", "src", "commands", "tenant", "models.ts");
const manifest = options.manifest ?? TENANT_SCOPE_MANIFEST;
const models = parsePrismaSchemaDirectory(schemaDirectory);
const manifestResult = validateTenantScopeManifest(models, manifest);
const registered = options.registeredLifecycleModels ?? listRegisteredLifecycleModels(modelsFile);
const directTenantModels = models.filter((model) => model.tenantField);
const directNames = new Set(directTenantModels.map((model) => model.model));
const missingLifecycleModels = directTenantModels.filter((model) => !registered.has(model.model));
const unknownLifecycleModels = [...registered].filter((model) => !directNames.has(model)).sort();
const errors = [
...manifestResult.errors,
...missingLifecycleModels.map((model) => `${model.schema}: ${model.model} has tenantId but is missing from TENANT_SCOPED_MODELS`),
...unknownLifecycleModels.map((model) => `${model} is registered in TENANT_SCOPED_MODELS but has no tenantId field`),
];
return {
ok: errors.length === 0,
errors,
models,
manifestResult,
directTenantModels,
missingLifecycleModels,
unknownLifecycleModels,
manifest,
};
}
export function formatCrossCuttingModels(result) {
const classifiedIndirect = [...result.manifestResult.indirectModels].filter((model) => {
const entry = result.manifest[model];
return entry?.scope === "derived-child" || entry?.scope === "platform-admin" || entry?.tenantParentBoundary === true;
}).length;
const lines = [
`${result.ok ? "PASS" : "FAIL"} cross-cutting model registry completeness`,
`Tenant scope manifest: ${result.manifestResult.classifiedCount}/${result.manifestResult.modelCount} Prisma models classified`,
`Indirect tenant paths: ${result.manifestResult.indirectModels.size} detected, ${classifiedIndirect} boundary-classified`,
`Lifecycle registry: ${result.directTenantModels.length - result.missingLifecycleModels.length}/${result.directTenantModels.length} direct tenantId models registered`,
];
for (const error of result.errors) lines.push(`FAIL ${error}`);
lines.push(`Summary: ${result.errors.length} missing or inconsistent classifications`);
return `${lines.join("\n")}\n`;
}
export function runCrossCuttingModels(rootDirectory = process.cwd()) {
const result = inspectCrossCuttingModels(rootDirectory);
(result.ok ? process.stdout : process.stderr).write(formatCrossCuttingModels(result));
return result.ok ? 0 : 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exitCode = runCrossCuttingModels();
}