#!/usr/bin/env node /** * TorbatYar frontend architecture validator (Phase 1). * Checks directory scaffold and cross-module import boundaries. * Does not modify source files. */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), ".."); const REQUIRED_DIRS = [ "modules", "shared", "shared/ui", "shared/forms", "shared/layouts", "shared/tables", "shared/charts", "shared/calendar", "shared/hooks", "shared/utils", "shared/api", "shared/theme", "shared/providers", "shared/icons", "shared/types", "shared/constants", "shared/design-system", "docs", ]; const SOURCE_DIRS = ["app", "components", "hooks", "lib", "modules", "src"]; const SOURCE_EXT = new Set([".ts", ".tsx"]); const SKIP_DIRS = new Set(["node_modules", ".next", "docs"]); /** @type {Record} */ const DOMAIN_PATH_MARKERS = { accounting: "src/modules/accounting", beauty: "modules/beauty", healthcare: "src/modules/healthcare", hospitality: "modules/hospitality", delivery: "modules/delivery", admin: "components/admin", }; /** Cross-domain import rules: file path prefix -> forbidden import substrings */ const CROSS_DOMAIN_RULES = [ { name: "accounting → other domains", fileTest: (rel) => rel.startsWith("app/accounting/") || rel.startsWith("src/modules/accounting/"), forbidden: [ "@/components/beauty/", "@/components/healthcare/", "@/modules/beauty/", "@/src/modules/healthcare/", "@/src/modules/beauty/", ], }, { name: "beauty → other domains", fileTest: (rel) => rel.startsWith("app/beauty/") || rel.startsWith("modules/beauty/"), forbidden: [ "@/components/accounting/", "@/components/healthcare/", "@/modules/accounting/", "@/src/modules/healthcare/", ], }, { name: "healthcare → other domains", fileTest: (rel) => rel.startsWith("app/healthcare/") || rel.startsWith("src/modules/healthcare/"), forbidden: [ "@/components/accounting/", "@/components/beauty/", "@/modules/beauty/", "@/modules/accounting/", "@/modules/hospitality/", "@/src/modules/beauty/", ], }, { name: "hospitality → other domains", fileTest: (rel) => rel.startsWith("app/hospitality/") || rel.startsWith("modules/hospitality/"), forbidden: [ "@/components/accounting/", "@/components/beauty/", "@/components/healthcare/", "@/modules/beauty/", "@/modules/accounting/", "@/modules/delivery/", "@/src/modules/healthcare/", ], }, { name: "delivery → other domains", fileTest: (rel) => rel.startsWith("app/delivery/") || rel.startsWith("modules/delivery/"), forbidden: [ "@/components/accounting/", "@/components/beauty/", "@/components/healthcare/", "@/modules/beauty/", "@/modules/accounting/", "@/modules/hospitality/", "@/src/modules/healthcare/", "@/src/modules/accounting/", ], }, { name: "design system → domains", fileTest: (rel) => rel.startsWith("components/ds/"), forbidden: [ "@/components/accounting/", "@/components/beauty/", "@/components/healthcare/", "@/modules/beauty/", "@/modules/hospitality/", "@/src/modules/healthcare/", "@/src/modules/accounting/", "@/modules/accounting/", ], }, { name: "shared → domain modules", fileTest: (rel) => rel.startsWith("shared/") || rel.startsWith("src/shared/"), forbidden: [ "@/components/accounting/", "@/components/beauty/", "@/components/healthcare/", "@/modules/beauty/", "@/modules/hospitality/", "@/src/modules/healthcare/", "@/src/modules/accounting/", "@/modules/accounting/", ], }, ]; const importRegex = /from\s+["']([^"']+)["']/g; /** @param {string} dir */ function walkSourceFiles(dir, files = []) { if (!existsSync(dir)) return files; for (const entry of readdirSync(dir)) { if (SKIP_DIRS.has(entry)) continue; const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) { walkSourceFiles(full, files); } else if (SOURCE_EXT.has(entry.slice(entry.lastIndexOf(".")))) { files.push(full); } } return files; } function checkDirectories() { /** @type {string[]} */ const missing = []; for (const dir of REQUIRED_DIRS) { const full = join(ROOT, dir); if (!existsSync(full)) missing.push(dir); else if (!existsSync(join(full, "README.md")) && dir !== "docs") { missing.push(`${dir}/README.md`); } } return missing; } function checkCrossDomainImports() { /** @type {Array<{file: string, rule: string, importPath: string}>} */ const violations = []; for (const srcDir of SOURCE_DIRS) { const abs = join(ROOT, srcDir); for (const file of walkSourceFiles(abs)) { const rel = relative(ROOT, file).split(sep).join("/"); const content = readFileSync(file, "utf8"); let match; importRegex.lastIndex = 0; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1]; for (const rule of CROSS_DOMAIN_RULES) { if (!rule.fileTest(rel)) continue; for (const forbidden of rule.forbidden) { if (importPath.startsWith(forbidden)) { violations.push({ file: rel, rule: rule.name, importPath }); } } } } } } // Also scan shared/ when it contains source files const sharedAbs = join(ROOT, "shared"); for (const file of walkSourceFiles(sharedAbs)) { const rel = relative(ROOT, file).split(sep).join("/"); const content = readFileSync(file, "utf8"); let match; importRegex.lastIndex = 0; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1]; for (const rule of CROSS_DOMAIN_RULES) { if (!rule.fileTest(rel)) continue; for (const forbidden of rule.forbidden) { if (importPath.startsWith(forbidden)) { violations.push({ file: rel, rule: rule.name, importPath }); } } } } } return violations; } function checkModulesScaffold() { /** @type {string[]} */ const issues = []; const modulesReadme = join(ROOT, "modules", "README.md"); if (!existsSync(modulesReadme)) { issues.push("modules/README.md missing"); } const beautyModule = join(ROOT, "modules", "beauty"); const healthcareModule = join(ROOT, "modules", "healthcare"); const healthcareSrc = join(ROOT, "src", "modules", "healthcare"); const accountingModule = join(ROOT, "src", "modules", "accounting"); if (!existsSync(beautyModule)) issues.push("modules/beauty/ missing"); if (!existsSync(healthcareSrc)) issues.push("src/modules/healthcare/ missing"); if (!existsSync(accountingModule)) issues.push("src/modules/accounting/ missing"); if (existsSync(healthcareModule) && !existsSync(healthcareSrc)) { issues.push("legacy modules/healthcare/ should not exist — use src/modules/healthcare/"); } return issues; } function main() { console.log("TorbatYar Frontend — Architecture Validation (Phase 1)\n"); let failed = false; const missing = checkDirectories(); if (missing.length) { failed = true; console.error("❌ Missing required paths:"); for (const m of missing) console.error(` - ${m}`); } else { console.log("✅ Directory scaffold complete (modules/, shared/*, docs/)"); } const moduleIssues = checkModulesScaffold(); if (moduleIssues.length) { failed = true; console.error("\n❌ modules/ scaffold issues:"); for (const i of moduleIssues) console.error(` - ${i}`); } else { console.log("✅ modules/beauty, src/modules/healthcare, src/modules/accounting migrated"); } const violations = checkCrossDomainImports(); if (violations.length) { failed = true; console.error(`\n❌ Cross-domain import violations (${violations.length}):`); for (const v of violations.slice(0, 20)) { console.error(` - ${v.file}: ${v.importPath} (${v.rule})`); } if (violations.length > 20) { console.error(` … and ${violations.length - 20} more`); } } else { console.log("✅ No cross-domain import violations detected"); } console.log("\n--- Summary ---"); console.log(`Root: ${ROOT}`); console.log(`Required directories: ${REQUIRED_DIRS.length}`); console.log(`Domain markers: ${Object.keys(DOMAIN_PATH_MARKERS).join(", ")}`); if (failed) { console.error("\nArchitecture validation FAILED"); process.exit(1); } console.log("\nArchitecture validation PASSED"); process.exit(0); } main();