diff --git a/AGENTS.md b/AGENTS.md index bbe1392c3..e4aeb68d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,7 +251,7 @@ OpenPencil follows a Reka UI-inspired component namespace structure: - Non-component domain folders use lowercase or kebab-case: `scene-graph/`, `figma-api/`, `node-edit/`. - Non-component TypeScript files use lowercase or kebab-case unless they are conventional entrypoints such as `index.ts`, `types.ts`, `context.ts`, or `use.ts`. - Multi-file root components live inside their component namespace folder, not beside it. -- Use subfolders for multi-file domains instead of sibling files with repeated prefixes. Prefer `selection/container.ts`, `selection/hit-test.ts` over `selection-container.ts`, `selection-hit-test.ts`. When adding a second file for a domain (e.g. `eval-wrap.ts` next to `eval.ts`), create the folder immediately (`eval/index.ts` + `eval/wrap.ts`) instead of prefixing. The lint rule `no-sibling-domain-prefixed-files` catches this when a sibling folder exists, but the convention applies even before the folder is created. +- Use subfolders for multi-file domains instead of sibling files with repeated prefixes. Prefer `selection/container.ts`, `selection/hit-test.ts` over `selection-container.ts`, `selection-hit-test.ts`. When adding a second file for a domain (e.g. `eval-wrap.ts` next to `eval.ts`), create the folder immediately (`eval/index.ts` + `eval/wrap.ts`) instead of prefixing. Oxlint catches sibling prefix files when a sibling folder exists; Steiger catches 3+ sibling files with the same prefix. The convention applies even before either rule triggers. - `@/` import alias for app cross-directory imports; app feature code lives under `src/app/*` - Use package-local aliases inside workspace packages: `#vue/*` in `packages/vue`, `#cli/*` in `packages/cli`, `#mcp/*` in `packages/mcp`, and `#core/*` when core code needs an alias. Prefer relative imports within nearby core modules when that is clearer than an alias. diff --git a/scripts/steiger-rules.ts b/scripts/steiger-rules.ts index ca3ab320e..e1d007572 100644 --- a/scripts/steiger-rules.ts +++ b/scripts/steiger-rules.ts @@ -17,6 +17,11 @@ type Rule = { name: string; check: (root: TreeEntry) => RuleResult } type FileRuleCheck = (sourceRel: string) => string | null +const FILE_PREFIX_GROUP_ALLOWLIST = new Set([ + 'packages/core/src/lint/rules::no', + 'tests/engine::visual' +]) + type ImportRef = { specifier: string line: number @@ -62,6 +67,13 @@ function collectFiles(entry: TreeEntry, files: string[] = []) { return files } +function collectFolders(entry: TreeEntry, folders: TreeEntry[] = []) { + if (entry.type !== 'folder') return folders + folders.push(entry) + for (const child of entry.children ?? []) collectFolders(child, folders) + return folders +} + function importsIn(content: string): ImportRef[] { const imports: ImportRef[] = [] const patterns = [ @@ -139,6 +151,40 @@ function createImportRule( } } +function filePrefix(filePath: string): string | null { + const name = path.basename(filePath).replace(/\.(test|spec|bench)?\.?[cm]?[tj]sx?$|\.vue$/, '') + const match = /^([a-z][a-z0-9]+)-[a-z0-9-]+$/.exec(name) + return match?.[1] ?? null +} + +const preferDomainFoldersOverFilenamePrefixes: Rule = { + name: 'open-pencil/prefer-domain-folders-over-filename-prefixes', + check(root) { + const diagnostics: Diagnostic[] = [] + for (const folder of collectFolders(root)) { + const folderRel = relativePath(root.path, folder.path) + const groups = new Map() + for (const child of folder.children ?? []) { + if (child.type !== 'file' || !TEXT_EXTENSIONS.has(path.extname(child.path))) continue + const prefix = filePrefix(child.path) + if (!prefix) continue + const files = groups.get(prefix) ?? [] + files.push(child.path) + groups.set(prefix, files) + } + for (const [prefix, files] of groups) { + if (files.length < 3) continue + if (FILE_PREFIX_GROUP_ALLOWLIST.has(`${folderRel}::${prefix}`)) continue + diagnostics.push({ + message: `Use a ${prefix}/ domain folder instead of ${files.length} sibling files with the ${prefix}- filename prefix.`, + location: { path: folder.path } + }) + } + } + return { diagnostics } + } +} + const strictTestFilePlacement = createFileRule('open-pencil/strict-test-file-placement', (sourceRel) => { if (!sourceRel.startsWith('tests/')) return null if (!TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null @@ -350,6 +396,7 @@ const noUiImportsInCore = createImportRule( export const openPencilArchitecturePlugin = { meta: { name: 'open-pencil-architecture', version: '0.0.0' }, ruleDefinitions: [ + preferDomainFoldersOverFilenamePrefixes, strictTestFilePlacement, noEngineOnlyAssertionsInE2E, noE2EImportsInEngineTests, diff --git a/steiger.config.ts b/steiger.config.ts index 0726119f0..1e137d73b 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -21,6 +21,7 @@ export default defineConfig([ }, { rules: { + 'open-pencil/prefer-domain-folders-over-filename-prefixes': 'error', 'open-pencil/strict-test-file-placement': 'error', 'open-pencil/no-engine-only-assertions-in-e2e': 'error', 'open-pencil/no-e2e-imports-in-engine-tests': 'error',