## Summary
- Re-implements `eslint-plugin-project-structure`'s folder structure
enforcement as a custom oxlint rule (`twenty/folder-structure`),
recovering functionality lost during the ESLint → Oxlint migration
- Validates `src/modules/` structure: kebab-case module folder names,
allowed subdirectories (hooks, utils, components, states, types,
graphql, etc.), hook file naming (`use{PascalCase}.(ts|tsx)`), util file
naming (`{camelCase}.(ts|tsx)`), and module nesting depth (max 4 levels)
- Enabled as `"warn"` in twenty-front with 403 pre-existing violations
to address incrementally
## What the rule checks
| Check | Example valid | Example invalid |
|-------|-------------|-----------------|
| Module names kebab-case | `object-record/` | `graphWidgetBarChart/` |
| Allowed subdirs only | `hooks/`, `components/`, `utils/` |
`random-stuff/` |
| Hook file naming | `useMyHook.ts` | `badName.ts` |
| Util file naming | `buildQuery.ts` | `build-query.ts` |
| Max nesting depth 4 | `a/b/c/d/hooks/` | `a/b/c/d/e/hooks/` |
| Utils kebab-case subfolders | `utils/cron-to-human/` |
`utils/camelCase/` |
## Pre-existing violations (403 total)
| Category | Count | Examples |
|----------|-------|---------|
| Non-kebab-case module names | 160 | `graphWidgetBarChart`,
`AIChatThreads` |
| Module depth > 4 | 215 |
`settings/roles/role-permissions/object-level-permissions/field-permissions`
|
| Util file naming | 22 | `.util.ts` suffix, kebab-case, PascalCase
filenames |
| Misc (hooks, tests) | 6 | Non-hook files in hooks/, folders in test
dirs |
362 lines
8.3 KiB
TypeScript
362 lines
8.3 KiB
TypeScript
import { RuleTester } from 'oxlint/plugins-dev';
|
|
|
|
import { rule, RULE_NAME } from './folder-structure';
|
|
|
|
const ruleTester = new RuleTester();
|
|
|
|
const DUMMY_CODE = 'const x = 1;';
|
|
const PROJECT = '/project/packages/twenty-front';
|
|
|
|
const filename = (path: string) => `${PROJECT}/${path}`;
|
|
|
|
ruleTester.run(RULE_NAME, rule, {
|
|
valid: [
|
|
// Files outside src/modules/ are ignored
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/utils/helper.ts'),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/components/Button.tsx'),
|
|
},
|
|
|
|
// types/ root folder allows anything
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/modules/types/global.d.ts'),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/modules/types/nested/deep.ts'),
|
|
},
|
|
|
|
// Hooks: use{PascalCase}.(ts|tsx)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/useCreateOneRecord.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/useMyHook.tsx',
|
|
),
|
|
},
|
|
|
|
// Hook tests: use{PascalCase}.(test|spec).(ts|tsx)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/__tests__/useMyHook.test.tsx',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/__tests__/useMyHook.spec.tsx',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/__tests__/__snapshots__/snap.ts',
|
|
),
|
|
},
|
|
|
|
// Hooks internal
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/internal/useInternalHook.ts',
|
|
),
|
|
},
|
|
|
|
// Hooks __mocks__
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/hooks/__mocks__/useMyHook.ts',
|
|
),
|
|
},
|
|
|
|
// Utils: {camelCase}.(ts|tsx)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/utils/buildQuery.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/utils/getCardComponent.tsx',
|
|
),
|
|
},
|
|
|
|
// Util tests: {camelCase}.(test|spec).(ts|tsx)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/utils/__tests__/buildQuery.test.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/utils/__tests__/buildQuery.spec.ts',
|
|
),
|
|
},
|
|
|
|
// Utils kebab-case subfolders (leaf directories)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/workflow/utils/cron-to-human/parseCron.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/workflow/utils/cron-to-human/types/CronParts.ts',
|
|
),
|
|
},
|
|
|
|
// Standard allowed subdirs
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/components/RecordTable.tsx',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/states/someState.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/constants/DEFAULT_VALUE.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/graphql/queries.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/effect-components/MyEffect.tsx',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/validation-schemas/schema.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/contexts/MyContext.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/scopes/MyScope.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/services/recordService.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/errors/NotFoundError.ts',
|
|
),
|
|
},
|
|
|
|
// Nested modules up to depth 4
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/sub-module/components/Widget.tsx',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/modules/a/b/c/d/hooks/useDeep.ts'),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/a/b/c/d/e/hooks/useDeep.ts',
|
|
),
|
|
},
|
|
|
|
// Files directly in module folders
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/modules/my-feature/index.ts'),
|
|
},
|
|
|
|
// __tests__ inside module
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/object-record/__tests__/integration.test.ts',
|
|
),
|
|
},
|
|
|
|
// Test helpers and non-standard files in __tests__/ are allowed (leaf context)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/hooks/__tests__/TestWrapper.tsx',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/hooks/__tests__/constants/mockData.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/utils/__tests__/helpers/setup.ts',
|
|
),
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/utils/__tests__/kebab-name.test.js',
|
|
),
|
|
},
|
|
],
|
|
|
|
invalid: [
|
|
// Files directly in modules root
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename('src/modules/loose-file.ts'),
|
|
errors: [{ messageId: 'noFilesInModulesRoot' }],
|
|
},
|
|
|
|
// Non-kebab-case module names
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/MyModule/hooks/useHook.ts',
|
|
),
|
|
errors: [{ messageId: 'moduleNameNotKebabCase' }],
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/camelCase/hooks/useHook.ts',
|
|
),
|
|
errors: [{ messageId: 'moduleNameNotKebabCase' }],
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/graphWidgetChart/file.ts',
|
|
),
|
|
errors: [{ messageId: 'moduleNameNotKebabCase' }],
|
|
},
|
|
|
|
// Module nesting too deep (depth > 5)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/a/b/c/d/e/f/hooks/useDeep.ts',
|
|
),
|
|
errors: [{ messageId: 'moduleTooDeep' }],
|
|
},
|
|
// Depth check fires before kebab-case check at max depth
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/a/b/c/d/e/camelCaseDir/file.ts',
|
|
),
|
|
errors: [{ messageId: 'moduleTooDeep' }],
|
|
},
|
|
|
|
// Bad hook file naming
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/hooks/badName.ts',
|
|
),
|
|
errors: [{ messageId: 'hookFileNaming' }],
|
|
},
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/hooks/helper.ts',
|
|
),
|
|
errors: [{ messageId: 'hookFileNaming' }],
|
|
},
|
|
|
|
// Invalid hooks folder entry
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/hooks/random-folder/file.ts',
|
|
),
|
|
errors: [{ messageId: 'invalidHooksEntry' }],
|
|
},
|
|
|
|
// Bad util file naming (PascalCase not allowed)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/utils/BadName.ts',
|
|
),
|
|
errors: [{ messageId: 'utilFileNaming' }],
|
|
},
|
|
// Bad util file naming (kebab-case files not allowed)
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/utils/kebab-name.ts',
|
|
),
|
|
errors: [{ messageId: 'utilFileNaming' }],
|
|
},
|
|
|
|
// Non-kebab-case subfolder in utils
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/utils/camelCase/file.ts',
|
|
),
|
|
errors: [{ messageId: 'invalidUtilsEntry' }],
|
|
},
|
|
|
|
// hooks/internal/ too deep
|
|
{
|
|
code: DUMMY_CODE,
|
|
filename: filename(
|
|
'src/modules/my-feature/hooks/internal/internal/internal/useHook.ts',
|
|
),
|
|
errors: [{ messageId: 'hooksInternalTooDeep' }],
|
|
},
|
|
],
|
|
});
|