TorbatYar/frontend/scripts/validate-architecture.mjs
Mortezakoohjani 6f4a484051 Migrate Beauty, Healthcare, and Accounting frontend to modular src/modules architecture.
Move business logic out of App Router into module packages, add boundary validation scripts, and keep all routes as thin re-exports without changing URLs or API behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:28:27 +03:30

279 lines
8.5 KiB
JavaScript

#!/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<string, string>} */
const DOMAIN_PATH_MARKERS = {
accounting: "src/modules/accounting",
beauty: "modules/beauty",
healthcare: "src/modules/healthcare",
hospitality: "modules/hospitality",
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/",
"@/src/modules/healthcare/",
],
},
{
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();