import path from "node:path"; import fg from "fast-glob"; /** * Filesystem naming rules for `measure structure`. * * Earlier iterations delegated to `@ls-lint/ls-lint` (= a Go binary). * That worked but coupled a first-party tool to a fairly large external * dependency for the sake of ~4 matchers we actually used. The rule set * is small and stable enough to own outright; this module is the result. * * Shape mirrors the prior YAML config: * * modules: * .dir applies to immediate children of `modules/` * "*" applies inside each module * db/.ts, command/.ts, query/.ts, lib/.ts, ... etc. * * apps: * .dir applies to immediate children of `apps/` * "*" applies inside each app * backend/src/resolver/.ts, frontend/src/components/.tsx * * Which dirs may exist under a module/app root is not a naming rule — * `structure.ts` owns that as its allow-list. */ export type Matcher = "kebab-case" | "camelCase" | "PascalCase" | { regex: string }; interface RuleNode { match?: Partial>; // keys: ".dir" or "." children?: Record; // key "*" = wildcard segment } const MODULE_LEAF_FILES: Matcher = { regex: "^(module|index|tailor\\.config|vitest\\.config|oxlint\\.config)$", }; const COMMAND_LIKE_TS: Matcher = { regex: "^[a-z][a-zA-Z0-9]*(\\.test|\\.generated)?$", }; // lib/ is not types-only: shared helpers are camelCase or `_`-prefixed. const LIB_TS: Matcher = { regex: "^([a-z][a-zA-Z0-9]*|_[a-zA-Z0-9_]+)$", }; const RESOLVER_TS: Matcher = { regex: "^[a-z][a-zA-Z0-9]*(\\.test)?$" }; const RULES: RuleNode = { children: { modules: { match: { ".dir": "kebab-case" }, children: { "*": { match: { ".ts": MODULE_LEAF_FILES }, children: { db: { match: { ".ts": "camelCase" } }, command: { match: { ".ts": COMMAND_LIKE_TS } }, query: { match: { ".ts": COMMAND_LIKE_TS } }, domain: { match: { ".ts": "camelCase" } }, repository: { match: { ".ts": "camelCase" } }, lib: { match: { ".ts": LIB_TS } }, executor: { match: { ".ts": "camelCase" } }, seed: { match: { ".ts": "camelCase" } }, testing: { match: { ".ts": "camelCase" } }, }, }, }, }, apps: { match: { ".dir": "kebab-case" }, children: { "*": { children: { backend: { children: { src: { children: { resolver: { match: { ".ts": RESOLVER_TS } }, }, }, }, }, frontend: { children: { src: { children: { components: { match: { ".tsx": "PascalCase" } }, }, }, }, }, }, }, }, }, }, }; // Tool-owned dirs: dependency / build output, never part of the authored shape. // Dot-prefixed output dirs are not listed — fast-glob skips those by default. export const TOOL_OWNED_DIRS = ["node_modules", "dist", "coverage"]; // `generated/` is naming-exempt only — its names are tool-driven, but as a dir // it is a legitimate module child (allow-listed in structure.ts, not tool-owned). const IGNORE_GLOBS = [...TOOL_OWNED_DIRS, "generated"].map((d) => `**/${d}/**`); export interface NamingViolation { path: string; rule: string; } function applyMatcher(m: Matcher, name: string): boolean { if (typeof m === "string") { switch (m) { case "kebab-case": return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name); case "camelCase": return /^[a-z][a-zA-Z0-9]*$/.test(name); case "PascalCase": return /^[A-Z][a-zA-Z0-9]*$/.test(name); } } return new RegExp(m.regex).test(name); } function formatMatcher(key: string, m: Matcher): string { if (typeof m === "string") return `${key}:${m}`; return `${key}:regex:${m.regex}`; } /** * Walks the rule tree along the given parent segments and returns the * rule node that applies to a child entry. Falls back to "*" at each * level when no literal segment match exists. Returns `null` when the * path runs deeper than the rule tree defines (= no naming rules apply * at that depth). */ function resolveNode(parentSegments: string[]): RuleNode | null { let node: RuleNode = RULES; for (const seg of parentSegments) { const next = node.children?.[seg] ?? node.children?.["*"]; if (!next) return null; node = next; } return node; } function splitNameExt(basename: string): { stem: string; ext: string } { if (basename.endsWith(".d.ts")) { return { stem: basename.slice(0, -".d.ts".length), ext: ".d.ts" }; } const idx = basename.lastIndexOf("."); if (idx <= 0) return { stem: basename, ext: "" }; return { stem: basename.slice(0, idx), ext: basename.slice(idx) }; } export async function findNamingViolations(cwd: string): Promise { const violations: NamingViolation[] = []; // Deliberately fixed to repo-root modules/ and apps/, unlike the dir checks in // structure.ts: non-default roots (core's self:verify) are not naming-scanned (#741). const [dirs, files] = await Promise.all([ fg(["modules/**", "apps/**"], { cwd, onlyDirectories: true, ignore: IGNORE_GLOBS, }), fg(["modules/**", "apps/**"], { cwd, onlyFiles: true, ignore: IGNORE_GLOBS, }), ]); for (const dir of dirs) { const segments = dir.split("/"); const parentSegments = segments.slice(0, -1); const name = segments[segments.length - 1] ?? ""; const node = resolveNode(parentSegments); const matcher = node?.match?.[".dir"]; if (matcher && !applyMatcher(matcher, name)) { violations.push({ path: dir, rule: formatMatcher(".dir", matcher) }); } } for (const file of files) { const segments = file.split("/"); const basename = segments[segments.length - 1] ?? ""; const { stem, ext } = splitNameExt(basename); if (!ext) continue; // Tool-/convention-driven names, not authored surface — never naming-checked. if (stem.endsWith(".generated") || stem.endsWith(".test")) continue; const parentSegments = segments.slice(0, -1); const node = resolveNode(parentSegments); const matcher = node?.match?.[ext]; if (matcher && !applyMatcher(matcher, stem)) { violations.push({ path: file, rule: formatMatcher(ext, matcher) }); } } return violations.sort((a, b) => a.path.localeCompare(b.path)); } export function classifyViolationBucket( violationPath: string, modulesRoot: string | undefined, appRoot: string | undefined, ): "modules" | "apps" | "other" { if ( modulesRoot && violationPath.startsWith(`${modulesRoot}${path.sep === "\\" ? path.sep : "/"}`) ) return "modules"; if (appRoot && violationPath.startsWith(`${appRoot}/`)) return "apps"; if (violationPath.startsWith("modules/")) return "modules"; if (violationPath.startsWith("apps/")) return "apps"; return "other"; }