Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-phase-c-boundaries.mjs
T

235 lines
8.5 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const requiredPackageNamesByDir = new Map([
["packages/foundation", "@toolctrl/foundation"],
["packages/view-models", "@toolctrl/view-models"],
["packages/ui-app", "@toolctrl/ui-app"],
["packages/ui-subframe", "@toolctrl/ui-subframe"],
["packages/ui-mock", "@toolctrl/ui-mock"],
]);
const packageDirs = [...requiredPackageNamesByDir.keys()];
const packageForbiddenDependencies = new Map([
["packages/foundation", ["@toolctrl/db", "react", "react-dom"]],
["packages/view-models", ["@toolctrl/db", "react", "react-dom"]],
["packages/ui-app", ["@toolctrl/db"]],
["packages/ui-subframe", ["@toolctrl/db"]],
["packages/ui-mock", ["@toolctrl/db"]],
]);
const violations = [];
for (const dir of packageDirs) {
const packageJsonPath = path.join(root, dir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
violations.push(`Missing package manifest: ${dir}/package.json`);
continue;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const expectedPackageName = requiredPackageNamesByDir.get(dir);
if (packageJson.name !== expectedPackageName) {
violations.push(`Unexpected package name in ${dir}/package.json: expected ${expectedPackageName}, got ${packageJson.name}`);
}
const forbiddenDependencies = packageForbiddenDependencies.get(dir) ?? [];
const declaredDependencies = getDeclaredBoundaryDependencies(packageJson);
for (const dependencyName of forbiddenDependencies) {
if (declaredDependencies[dependencyName]) {
violations.push(`${dir}/package.json declares forbidden dependency ${dependencyName}`);
}
}
}
const workspace = fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8");
if (!workspace.includes('"packages/*"')) {
violations.push("Workspace must include packages/* so Phase C packages are installable.");
}
scanDir("packages/foundation/src", (file, source) => {
collectImportViolations(file, source, ["react", "react-dom", "~/", "app/", "@toolctrl/db", "@toolctrl/ui-app", "@toolctrl/ui-mock"], violations);
});
scanDir("packages/view-models/src", (file, source) => {
collectImportViolations(file, source, ["react", "react-dom", "@toolctrl/db", "~/", "app/"], violations);
});
scanDir("packages/ui-app/src", (file, source) => {
collectImportViolations(file, source, ["@toolctrl/db", "~/utils/db", "packages/db"], violations);
});
scanDir("packages/ui-subframe/src", (file, source) => {
collectImportViolations(file, source, ["@toolctrl/db", "~/utils/db", "packages/db"], violations);
});
scanDir("packages/ui-mock/src", (file, source) => {
collectImportViolations(file, source, ["@toolctrl/db", "~/utils/db", "packages/db"], violations);
});
const negativeFixtureExpectations = [
{
file: "scripts/fixtures/phase-c-boundaries/foundation-react.fixture.txt",
forbidden: ["react"],
expected: "imports forbidden boundary react",
},
{
file: "scripts/fixtures/phase-c-boundaries/view-model-db.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "imports forbidden boundary @toolctrl/db",
},
{
file: "scripts/fixtures/phase-c-boundaries/ui-app-db.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "imports forbidden boundary @toolctrl/db/client.server",
},
{
file: "scripts/fixtures/phase-c-boundaries/foundation-dynamic-db.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "imports forbidden boundary @toolctrl/db",
},
{
file: "scripts/fixtures/phase-c-boundaries/ui-subframe-require-db.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "imports forbidden boundary @toolctrl/db/client.server",
},
{
file: "scripts/fixtures/phase-c-boundaries/ui-app-relative-db.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "imports forbidden boundary ../../../packages/db/src/client.server",
},
{
file: "scripts/fixtures/phase-c-boundaries/foundation-computed-import.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "uses non-literal dynamic import",
},
{
file: "scripts/fixtures/phase-c-boundaries/ui-mock-computed-require.fixture.txt",
forbidden: ["@toolctrl/db"],
expected: "uses non-literal require",
},
];
for (const fixture of negativeFixtureExpectations) {
const fixturePath = path.join(root, fixture.file);
if (!fs.existsSync(fixturePath)) {
violations.push(`${fixture.file}: missing negative boundary fixture`);
continue;
}
const fixtureViolations = [];
collectImportViolations(fixture.file, fs.readFileSync(fixturePath, "utf8"), fixture.forbidden, fixtureViolations);
if (!fixtureViolations.some((violation) => violation.includes(fixture.expected))) {
violations.push(`${fixture.file}: negative boundary fixture did not trigger ${fixture.expected}`);
}
}
const webPackage = JSON.parse(fs.readFileSync(path.join(root, "apps/web/package.json"), "utf8"));
for (const scriptName of ["dev", "build", "typecheck", "test", "start"]) {
if (!webPackage.scripts?.[scriptName]) {
violations.push(`@toolctrl/web missing ${scriptName} script.`);
}
}
const rootPackage = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
for (const scriptName of ["dev", "build", "typecheck", "start"]) {
if (!rootPackage.scripts?.[scriptName]?.includes("@toolctrl/web")) {
violations.push(`Root ${scriptName} script must delegate to @toolctrl/web.`);
}
}
if (violations.length > 0) {
console.error(violations.map((violation) => `- ${violation}`).join("\n"));
process.exit(1);
}
console.log("Phase C boundaries verified.");
function scanDir(relativeDir, callback) {
const absoluteDir = path.join(root, relativeDir);
if (!fs.existsSync(absoluteDir)) {
violations.push(`Missing required boundary scan root: ${relativeDir}`);
return;
}
for (const file of walk(absoluteDir)) {
if (!/\.(ts|tsx)$/.test(file)) continue;
callback(path.relative(root, file), fs.readFileSync(file, "utf8"));
}
}
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const fullPath = path.join(dir, entry.name);
return entry.isDirectory() ? walk(fullPath) : [fullPath];
});
}
function collectImportViolations(file, source, forbiddenSpecifiers, target) {
const imports = [
...source.matchAll(/\b(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?["']([^"']+)["']/g),
...source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g),
...source.matchAll(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g),
];
const computedDynamicImports = source.matchAll(/\bimport\s*\(\s*(?!["'])/g);
const computedRequires = source.matchAll(/\brequire\s*\(\s*(?!["'])/g);
for (const _match of computedDynamicImports) {
target.push(`${file} uses non-literal dynamic import`);
}
for (const _match of computedRequires) {
target.push(`${file} uses non-literal require`);
}
for (const match of imports) {
const specifier = match[1];
for (const forbidden of forbiddenSpecifiers) {
if (isForbiddenBoundarySpecifier(specifier, forbidden) || isForbiddenRelativeBoundarySpecifier(file, specifier, forbidden, root)) {
target.push(`${file} imports forbidden boundary ${specifier}`);
}
}
}
}
export function isForbiddenBoundarySpecifier(specifier, forbidden) {
return specifier === forbidden || specifier.startsWith(`${forbidden}/`);
}
export function getDeclaredBoundaryDependencies(packageJson) {
return {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies,
...packageJson.optionalDependencies,
};
}
export function isForbiddenRelativeBoundarySpecifier(file, specifier, forbidden, rootDir = process.cwd()) {
if (!specifier.startsWith(".")) {
return false;
}
const forbiddenRoot = forbiddenBoundaryRoot(forbidden, rootDir);
if (!forbiddenRoot) {
return false;
}
const resolved = path.resolve(rootDir, path.dirname(file), specifier);
const relative = path.relative(forbiddenRoot, resolved);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function forbiddenBoundaryRoot(forbidden, rootDir) {
if (forbidden.startsWith("@toolctrl/")) {
return path.join(rootDir, "packages", forbidden.slice("@toolctrl/".length));
}
if (forbidden === "packages/db") {
return path.join(rootDir, "packages", "db");
}
if (forbidden.startsWith("~/")) {
return path.join(rootDir, "apps", "web", "app", forbidden.slice("~/".length));
}
if (forbidden === "app/" || forbidden === "app") {
return path.join(rootDir, "apps", "web", "app");
}
return null;
}