diff --git a/.eslintrc.react.cjs b/.eslintrc.react.cjs index 77c307222b00..e621d96af2a5 100644 --- a/.eslintrc.react.cjs +++ b/.eslintrc.react.cjs @@ -48,6 +48,7 @@ module.exports = { '@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error', '@nx/workspace-useRecoilCallback-has-dependency-array': 'error', '@nx/workspace-no-navigate-prefer-link': 'error', + '@nx/workspace-folder-structure': 'warn', 'react/no-unescaped-entities': 'off', 'react/prop-types': 'off', 'react/jsx-key': 'off', diff --git a/tools/eslint-rules/index.ts b/tools/eslint-rules/index.ts index 98419b674d32..22121547bbeb 100644 --- a/tools/eslint-rules/index.ts +++ b/tools/eslint-rules/index.ts @@ -51,6 +51,11 @@ import { RULE_NAME as useRecoilCallbackHasDependencyArrayName, } from './rules/useRecoilCallback-has-dependency-array'; +import { + rule as folderStructureRule, + RULE_NAME as folderStructureRuleName, +} from './rules/folder-structure-rule'; + /** * Import your custom workspace rules at the top of this file. * @@ -93,5 +98,6 @@ module.exports = { useRecoilCallbackHasDependencyArray, [noNavigatePreferLinkName]: noNavigatePreferLink, [injectWorkspaceRepositoryName]: injectWorkspaceRepository, + [folderStructureRuleName]: folderStructureRule, }, }; diff --git a/tools/eslint-rules/rules/folder-structure-rule.ts b/tools/eslint-rules/rules/folder-structure-rule.ts new file mode 100644 index 000000000000..21395d3c4aa5 --- /dev/null +++ b/tools/eslint-rules/rules/folder-structure-rule.ts @@ -0,0 +1,239 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; +import fs from 'fs'; +import path from 'path'; +import { + CASES, + ExtensionsType, + FolderRule, + NameValidationType, + RULES, + configs, + stringifyConfig, +} from './folderStructureConfig'; + +export const RULE_NAME = 'folder-structure'; + +const validateString = (input: string, validator: NameValidationType) => { + const { + prefix = '', + suffix = '', + namePattern, + extension = '', + } = typeof validator === 'string' + ? { namePattern: validator } + : validator || {}; + + const prefixArray = Array.isArray(prefix) ? prefix : [prefix]; + const suffixArray = Array.isArray(suffix) ? suffix : [suffix]; + const nameArray = Array.isArray(namePattern) ? namePattern : [namePattern]; + const extensionArray = Array.isArray(extension) ? extension : [extension]; + + let fileName = input; + + if (namePattern === '*') { + return true; + } + + // Check for prefix + const foundPrefix = prefix + ? prefixArray.find((pfx) => fileName.startsWith(pfx)) + : ''; + + if (foundPrefix === undefined) { + return false; + } + + fileName = fileName.substring(foundPrefix.length); + + if (extension) { + const extIndex = fileName.indexOf('.'); + if (extIndex === -1) { + return false; + } + + const extractedExt = fileName.substring(extIndex + 1) as ExtensionsType; + const foundExt = extensionArray.find((ext) => ext === extractedExt); + if (foundExt === undefined) { + return false; + } + + fileName = fileName.substring(0, extIndex); + + if (foundExt.endsWith('tsx')) { + if (foundExt.endsWith('test.tsx') && foundPrefix === 'use') { + return CASES['StrictPascalCase'].test(fileName); + } + return CASES['StrictPascalCase'].test(foundPrefix + fileName); + } + + if (foundExt.endsWith('ts')) { + return ( + CASES['StrictCamelCase'].test(foundPrefix + fileName) || + CASES['kebab-case'].test(foundPrefix + fileName) + ); + } + } + + // Check for suffix + const foundSuffix = suffix + ? suffixArray.find((sfx) => fileName.endsWith(sfx)) + : ''; + + if (foundSuffix === undefined) { + return false; + } + + fileName = fileName.substring(0, fileName.length - foundSuffix.length); + + // Check name cases + const nameValidated = namePattern + ? nameArray.find((caseName) => { + const nameRegex = CASES[caseName]; + return nameRegex ? nameRegex.test(fileName) : fileName === caseName; + }) + : ''; + + if (nameValidated === undefined) { + return false; + } + + return true; +}; +const invalid = {}; +const valid = {}; +// Recursive function to check folder structure based on config +const checkFolderStructure = ( + currentPath: string, + config: FolderRule, +): boolean => { + const { name, children, ruleId } = config; + + if (name) { + const folderName = currentPath.split('/').pop(); + const isNameValid = validateString(folderName, name); + + if (isNameValid) { + valid[currentPath] = true; + delete invalid[currentPath]; + if (!ruleId && (!children || children.length === 0)) { + return true; + } + } else { + const isAlreadyValid = valid[currentPath]; + if (isAlreadyValid) { + delete invalid[currentPath]; + } else { + const invalidPath = invalid[currentPath] || []; + invalid[currentPath] = [...invalidPath, { name }]; + } + return false; + } + } + + if (ruleId) { + const rule = RULES[ruleId]; + const folderName = currentPath.split('/').pop(); + + if (rule.reservedFolders && rule.reservedFolders.includes(folderName)) { + return false; + } + + return checkFolderStructure(currentPath, { + ...rule, + name: name ? undefined : rule.name, + }); + } + + if (children) { + const filesOrSubFolders = fs.readdirSync(currentPath, { + withFileTypes: true, + }); + const isolatedFiles = filesOrSubFolders.filter((file) => file.isFile()); + const isolatedFilesRules = children.filter( + (rule) => !rule.children && rule.name && !rule.ruleId, + ); + const subFolders = filesOrSubFolders.filter((file) => file.isDirectory()); + const subFoldersRules = children.filter( + (rule) => rule.children || rule.ruleId, + ); + + const areIsolatedFilesValid = + isolatedFiles.length === 0 || + isolatedFiles + .map((file) => { + if (isolatedFilesRules.length === 0) return; + const isFileValid = isolatedFilesRules.some((rule) => { + return checkFolderStructure(`${currentPath}/${file.name}`, rule); + }); + + return isFileValid; + }) + .every(Boolean); + + const areSubFoldersValid = + subFolders.length === 0 || + subFolders + .map((subFolder) => { + if (subFoldersRules.length === 0) return; + const isSubFolderValid = subFoldersRules.some((rule) => { + return checkFolderStructure( + `${currentPath}/${subFolder.name}`, + rule, + ); + }); + + return isSubFolderValid; + }) + .every(Boolean); + return areIsolatedFilesValid && areSubFoldersValid; + } + + return false; +}; + +let hasRun = false; + +// ESLint rule definition +export const rule = ESLintUtils.RuleCreator(() => __filename)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Enforce folder structure and naming conventions in the modules directory', + recommended: 'recommended', + }, + fixable: null, + schema: [], + messages: { + invalidName: + "Path name '{{ pathName }}' is invalid. Allowed patterns: '{{ expectedPatterns }}'.", + }, + }, + defaultOptions: [], + create: (context) => { + if (hasRun) return {}; + hasRun = true; + + const rootFolder = path.resolve( + __dirname, + '../../../packages/twenty-front/src', + ); + + checkFolderStructure(rootFolder, configs); + + Object.keys(invalid).forEach((invalidPath) => { + context.report({ + messageId: 'invalidName', + loc: { line: 1, column: 0 }, + data: { + pathName: invalidPath, + expectedPatterns: stringifyConfig(invalid[invalidPath]), + }, + fix: null, + }); + }); + + return {}; + }, +}); diff --git a/tools/eslint-rules/rules/folderStructureConfig.ts b/tools/eslint-rules/rules/folderStructureConfig.ts new file mode 100644 index 000000000000..80f014fe2f1a --- /dev/null +++ b/tools/eslint-rules/rules/folderStructureConfig.ts @@ -0,0 +1,396 @@ +export const CASES: Record = { + 'kebab-case': /^[a-z]+(-[a-z]+)*$/, + StrictCamelCase: /^[a-z]+((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?$/, + StrictPascalCase: /^[A-Z](([a-z0-9]+[A-Z]?)*)$/, +}; + +export type ExtensionsType = + | 'ts' + | 'tsx' + | 'perf.stories.tsx' + | 'stories.tsx' + | 'test.ts' + | 'utils.test.ts' + | 'utils.ts' + | 'util.ts' + | 'interface.ts' + | 'test.tsx' + | 'svg' + | 'png' + | 'factory.test.ts' + | 'enum.ts' + | 'd.ts' + | 'factory.ts' + | 'util.test.ts' + | 'docs.mdx'; + +export type NameType = keyof typeof CASES | string; +interface ValidationObject { + prefix?: string[] | string; + suffix?: string[] | string; + namePattern?: NameType[] | NameType; + extension?: ExtensionsType[] | ExtensionsType; +} + +const SUB_FOLDER_NAME_CONSTRAINT = 'sub-folder-is-not-allowed'; + +export type NameValidationType = ValidationObject | NameType; + +export const stringifyConfig = (configs: FolderRule[]) => { + const getStringOrArrayValue = (validator: string | string[]) => { + if (!validator) return undefined; + if (typeof validator === 'string') { + return validator; + } else { + return validator.sort().join(', '); + } + }; + + const result = configs + .map((config) => { + const namePattern = + !config.name || typeof config.name === 'string' + ? config.name + : getStringOrArrayValue(config.name.namePattern); + const extension = + !config.name || typeof config.name === 'string' + ? undefined + : getStringOrArrayValue(config.name.extension); + const prefix = + !config.name || typeof config.name === 'string' + ? undefined + : getStringOrArrayValue(config.name.prefix); + const suffix = + !config.name || typeof config.name === 'string' + ? undefined + : getStringOrArrayValue(config.name.suffix); + return { namePattern, extension, prefix, suffix }; + }) + .filter((formattedConfig, index, arr) => { + return ( + index === + arr.findIndex((config) => + ['namePattern', 'prefix', 'suffix', 'extension'].every( + (key) => formattedConfig[key] === config[key], + ), + ) + ); + }); + return JSON.stringify(result); +}; + +export const RULES: Record = { + StorybookFolder: { + name: '__stories__', + children: [ + { + name: { + extension: [ + 'ts', + 'tsx', + 'stories.tsx', + 'perf.stories.tsx', + 'docs.mdx', + ], + }, + }, + { name: SUB_FOLDER_NAME_CONSTRAINT, children: [] }, + ], + }, + ComponentFolderWithStories: { + name: 'components', + children: [ + { ruleId: 'StorybookFolder' }, + { + name: { + extension: ['tsx', 'ts'], + }, + }, + { + name: { namePattern: ['kebab-case', 'StrictPascalCase'] }, + ruleId: 'ComponentFolderWithStories', + }, + ], + }, + UtilsFolder: { + name: 'utils', + children: [ + // TODO: what is the correct rule for utils? + { + name: '__tests__', + children: [ + { + name: { + extension: ['test.ts', 'utils.test.ts', 'util.test.ts'], + }, + }, + ], + }, + { + name: { + extension: ['ts', 'utils.ts', 'util.ts'], + }, + }, + { name: 'kebab-case', ruleId: 'UtilsFolder' }, + ], + }, + TypesFolder: { + name: 'types', + children: [ + { + name: { + extension: ['ts', 'tsx', 'interface.ts', 'd.ts'], + }, + }, + { name: 'kebab-case', ruleId: 'TypesFolder' }, + ], + }, + HooksFolder: { + name: 'hooks', + children: [ + { + name: { + extension: ['ts'], + prefix: 'use', + }, + }, + + { + name: '__tests__', + children: [ + { + name: { + extension: ['ts', 'tsx', 'test.ts', 'test.tsx', 'util.test.ts'], + prefix: 'use', + }, + }, + ], + }, + { + ruleId: 'MocksFolder', + }, + { + name: 'kebab-case', + children: [ + { + name: { extension: ['ts', 'tsx'] }, + }, + ], + }, + ], + }, + ConstantsFolder: { + name: 'constants', + children: [{ name: { extension: ['ts'] } }], + }, + ServicesFolder: { + name: 'services', + children: [ + { + name: '__tests__', + children: [ + { + name: { + extension: ['test.ts', 'factory.test.ts'], + }, + }, + ], + }, + { + name: { + extension: ['ts', 'factory.test.ts', 'factory.ts'], + }, + }, + ], + }, + StatesFolder: { + name: 'states', + children: [ + { name: { extension: 'ts' } }, + { + name: 'kebab-case', + children: [ + { + name: { + extension: 'ts', + }, + }, + ], + }, + ], + }, + assetsFolder: { + name: 'assets', + children: [ + { + name: { + extension: ['png', 'svg'], + }, + }, + ], + }, + ScopeFolder: { + name: 'scopes', + children: [ + { + name: 'kebab-case', + children: [ + { + name: { extension: ['ts', 'tsx'] }, + }, + ], + }, + { name: { extension: ['ts', 'tsx'] } }, + ], + }, + MocksFolder: { + name: '__mocks__', + children: [ + { + name: { + extension: ['ts', 'tsx'], + }, + }, + ], + }, + ThemesFolder: { + name: 'theme', + children: [{ name: { extension: ['ts', 'tsx'] } }], + }, + TestsFolder: { + name: '__tests__', + children: [ + { + name: { + extension: ['test.ts', 'utils.test.ts'], + }, + }, + ], + }, + EnumsFolder: { + name: 'enums', + children: [{ name: { extension: 'enum.ts' } }], + }, + ModulesFolder: { + name: 'kebab-case', + reservedFolders: [ + 'components', + 'hooks', + 'constants', + 'types', + 'utils', + 'states', + 'assets', + 'scope', + 'services', + 'mocks', + 'themes', + '__tests__', + 'enums', + 'context', + 'graphql', + 'queries', + '__stories__', + '__mocks__', + ], + children: [ + { ruleId: 'ComponentFolderWithStories' }, + { ruleId: 'HooksFolder' }, + { ruleId: 'ConstantsFolder' }, + { ruleId: 'TypesFolder' }, + { ruleId: 'UtilsFolder' }, + { ruleId: 'StatesFolder' }, + { ruleId: 'assetsFolder' }, + { ruleId: 'ScopeFolder' }, + { ruleId: 'ServicesFolder' }, + { ruleId: 'MocksFolder' }, + { ruleId: 'ThemesFolder' }, + { ruleId: 'TestsFolder' }, + { ruleId: 'StorybookFolder' }, + { ruleId: 'EnumsFolder' }, + { + name: 'context', + children: [{ name: { extension: 'ts' } }], + }, + { + name: 'graphql', + children: [ + { + name: 'kebab-case', + children: [ + { + name: { + extension: 'ts', + }, + }, + { + name: 'kebab-case', + children: [ + { + name: { + extension: 'ts', + }, + }, + ], + }, + ], + }, + { name: { extension: 'ts' } }, + { ruleId: 'TypesFolder' }, + { ruleId: 'UtilsFolder' }, + ], + }, + { + name: 'queries', + children: [ + { name: { extension: 'ts' } }, + { + name: 'kebab-case', + children: [{ name: { extension: 'ts' } }], + }, + { ruleId: 'TestsFolder' }, + ], + }, + { + name: { + extension: ['ts', 'tsx'], + }, + }, + { + ruleId: 'ModulesFolder', + }, + ], + }, +}; + +export type FolderRule = { + name?: NameValidationType; + children?: FolderRule[]; + ruleId?: keyof typeof RULES; + reservedFolders?: string[]; +}; +export const configs: FolderRule = { + name: 'src', + children: [ + // src/modules + { + name: 'modules', + + ruleId: 'ModulesFolder', + }, + // src/pages + { + name: 'pages', + children: [ + { + name: 'kebab-case', + children: [ + { ruleId: 'StorybookFolder' }, + { name: { extension: 'tsx' } }, + ], + }, + ], + }, + { name: '*', children: [] }, + ], +};