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>
279 lines
8.5 KiB
JavaScript
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();
|