235 lines
8.5 KiB
JavaScript
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;
|
|
}
|