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; }