import fs from "node:fs/promises"; import path from "node:path"; import fg from "fast-glob"; import { type CommandResult, success } from "../../../lib/command-result"; import { APP_PATHS } from "../../../lib/paths"; import { classifyViolationBucket, findNamingViolations, TOOL_OWNED_DIRS } from "./naming-rules"; export type StructureViolationType = "missing-required-dir" | "unexpected-dir" | "naming-violation"; export interface StructureViolation { type: StructureViolationType; path: string; rule?: string; } export interface ModulesMeasure { count: number; violations: StructureViolation[]; } export interface AppStructureMeasure { count: number; violations: StructureViolation[]; } export interface MeasureStructureResult { modules?: ModulesMeasure; apps?: AppStructureMeasure; } // `query/` is optional (command-only modules are valid), `lib/` is optional // (shared helpers exist only where needed), `testing/` is optional (shared // fixtures only; tests live beside the code). const REQUIRED_MODULE_DIRS = ["db", "command", "docs"]; const OPTIONAL_MODULE_DIRS = [ "query", "domain", "repository", "lib", "testing", "generated", "seed", "executor", "internal", ]; const ALLOWED_MODULE_DIRS = [...REQUIRED_MODULE_DIRS, ...OPTIONAL_MODULE_DIRS]; const REQUIRED_APP_DIRS = ["backend", "frontend", "docs"]; const ALLOWED_APP_DIRS = [...REQUIRED_APP_DIRS]; // Layer names read off APP_PATHS rather than repeated as literals, so a rename // there carries over. `story` is excluded: it nests under business-flow. const APP_DOC_DIRS = [ APP_PATHS.docs.actor, APP_PATHS.docs.businessFlow, APP_PATHS.docs.resolver, APP_PATHS.docs.screen, ].map((p) => path.basename(p)); const REQUIRED_BACKEND_SRC_DIRS = [ path.basename(APP_PATHS.code.resolver), path.basename(path.dirname(APP_PATHS.tests.story)), ]; // `lib` is left out: nothing in the flow refers to it, unlike the three below. const REQUIRED_FRONTEND_SRC_DIRS = [path.basename(APP_PATHS.code.component), "pages", "graphql"]; const TOOL_OWNED_IGNORE = TOOL_OWNED_DIRS.map((d) => `**/${d}`); interface AppDirRule { /** Directory the rule applies to, relative to the app root. */ root: string; required: string[]; /** When set, children outside it are `unexpected-dir`. Omit to allow extras. */ allowed?: string[]; } // `docs/` takes no allow-list: hand-written documentation belongs next to the // generated docs it describes, and an extra directory breaks nothing. // // Every rule root must itself be required by an earlier rule — a rule whose root // is missing is skipped, so an unrequired segment silently disables every check // below it. That is why `src` is here. export const APP_DIR_RULES: AppDirRule[] = [ { root: "", required: REQUIRED_APP_DIRS, allowed: ALLOWED_APP_DIRS }, { root: "docs", required: APP_DOC_DIRS }, { root: "backend", required: ["src"] }, { root: "backend/src", required: REQUIRED_BACKEND_SRC_DIRS }, { root: "frontend", required: ["src", path.basename(APP_PATHS.tests.e2e)] }, { root: "frontend/src", required: REQUIRED_FRONTEND_SRC_DIRS }, ]; async function dirExists(absPath: string): Promise { try { const stat = await fs.stat(absPath); return stat.isDirectory(); } catch { return false; } } async function measureModules(modulesRoot: string, cwd: string): Promise { const moduleDirs = await fg(`${modulesRoot}/*`, { cwd, onlyDirectories: true, deep: 1, }); const violations: StructureViolation[] = []; const allowed = new Set(ALLOWED_MODULE_DIRS); for (const moduleDir of moduleDirs) { const absModule = path.join(cwd, moduleDir); for (const required of REQUIRED_MODULE_DIRS) { if (!(await dirExists(path.join(absModule, required)))) { violations.push({ type: "missing-required-dir", path: path.posix.join(moduleDir, required), }); } } const childDirs = await fg(`${moduleDir}/*`, { cwd, onlyDirectories: true, deep: 1, ignore: TOOL_OWNED_IGNORE, }); for (const child of childDirs) { if (!allowed.has(path.basename(child))) { violations.push({ type: "unexpected-dir", path: child }); } } } return { count: moduleDirs.length, violations }; } async function measureApps(appRoot: string, cwd: string): Promise { const violations: StructureViolation[] = []; for (const rule of APP_DIR_RULES) { const ruleRoot = rule.root ? path.posix.join(appRoot, rule.root) : appRoot; // A nested rule only applies once its own root exists: a missing `docs/` // is one violation, not one per canonical child. if (rule.root && !(await dirExists(path.join(cwd, ruleRoot)))) continue; for (const required of rule.required) { if (!(await dirExists(path.join(cwd, ruleRoot, required)))) { violations.push({ type: "missing-required-dir", path: path.posix.join(ruleRoot, required), }); } } if (!rule.allowed) continue; const allowed = new Set(rule.allowed); const childDirs = await fg(`${ruleRoot}/*`, { cwd, onlyDirectories: true, deep: 1, ignore: TOOL_OWNED_IGNORE, }); for (const child of childDirs) { if (!allowed.has(path.basename(child))) { violations.push({ type: "unexpected-dir", path: child }); } } } return { count: 1, violations }; } /** * `measure structure` reports filesystem-shape violations: * * - **naming-violation** — name / placement issues caught by the * in-house rule tree (see `./naming-rules.ts`). * - **missing-required-dir** — required directories absent from a module/app. * For modules: `db/`, `command/`, `docs/` (`query/` and `lib/` are optional — * command-only and helper-free modules are valid). For apps, see `APP_DIR_RULES`. * - **unexpected-dir** — a child dir outside an allowed set. Only the module * root and the app root restrict what else may exist (`ALLOWED_MODULE_DIRS`, * `ALLOWED_APP_DIRS`); the nested app rules only check their required layers. * * Content-level checks (resolver imports, direct DB ops, etc.) are * intentionally out of scope here. */ export async function collectMeasureStructure( config: { appRoot?: string; modulesRoot?: string }, cwd: string, ): Promise { const result: MeasureStructureResult = {}; const namingViolations = await findNamingViolations(cwd); const modulesNaming: StructureViolation[] = []; const appsNaming: StructureViolation[] = []; for (const nv of namingViolations) { const bucket = classifyViolationBucket(nv.path, config.modulesRoot, config.appRoot); const entry: StructureViolation = { type: "naming-violation", path: nv.path, rule: nv.rule, }; if (bucket === "modules") modulesNaming.push(entry); else if (bucket === "apps") appsNaming.push(entry); // "other" buckets are silently dropped — the rule tree only covers // modules/ and apps/, so any other path is outside the rule set. } if (config.modulesRoot) { const measure = await measureModules(config.modulesRoot, cwd); measure.violations.push(...modulesNaming); result.modules = measure; } if (config.appRoot) { const measure = await measureApps(config.appRoot, cwd); measure.violations.push(...appsNaming); result.apps = measure; } return result; } export async function runInternalMeasureStructure( config: { appRoot?: string; modulesRoot?: string }, cwd: string, ): Promise { const result = await collectMeasureStructure(config, cwd); console.log(formatMeasureStructureReport(result)); // No violation gating here — that belongs to `erp-kit verify`. return success(); } function formatMeasureStructureReport(result: MeasureStructureResult): string { return JSON.stringify(result, null, 2); }