From 49cdfdfd456aa1dbd6af89beb981828b6ab9679e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 1 Dec 2025 13:43:25 +0000 Subject: [PATCH] feat: lint rule for camelCase file names --- eslint-rules/fileNaming.mjs | 107 ++++++++++++++++++++++++++++++++++++ eslint.config.mjs | 19 ++++++- package-lock.json | 9 +++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 eslint-rules/fileNaming.mjs diff --git a/eslint-rules/fileNaming.mjs b/eslint-rules/fileNaming.mjs new file mode 100644 index 000000000..8eca1d3e3 --- /dev/null +++ b/eslint-rules/fileNaming.mjs @@ -0,0 +1,107 @@ +// @ts-check + +/** + * Custom ESLint rule to enforce camelCase file naming convention. + * @type {import('eslint').Rule.RuleModule} + */ +export const fileNamingRule = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce camelCase file naming convention', + recommended: true, + }, + messages: { + invalidFileName: + "File name '{{fileName}}' should be camelCase. Suggested: '{{suggested}}'", + }, + schema: [ + { + type: 'object', + properties: { + ignore: { + type: 'array', + items: { type: 'string' }, + description: 'Regex patterns for file names to ignore', + }, + }, + additionalProperties: false, + }, + ], + }, + + create(context) { + return { + Program(node) { + const filename = context.filename || context.getFilename(); + const options = context.options[0] || {}; + const ignorePatterns = (options.ignore || []).map( + (/** @type {string} */ pattern) => new RegExp(pattern) + ); + + // Extract just the file name from the full path + const parts = filename.split('/'); + const fileName = parts[parts.length - 1]; + + // Skip non-TypeScript files + if (!fileName.endsWith('.ts') && !fileName.endsWith('.tsx')) { + return; + } + + // Check if file matches any ignore pattern + for (const pattern of ignorePatterns) { + if (pattern.test(fileName)) { + return; + } + } + + // Get the base name (without extension) + // Handle test files: name.test.ts -> name + const baseName = fileName + .replace(/\.test\.ts$/, '') + .replace(/\.spec\.ts$/, '') + .replace(/\.test\.tsx$/, '') + .replace(/\.spec\.tsx$/, '') + .replace(/\.ts$/, '') + .replace(/\.tsx$/, ''); + + // Get the extension for suggestion + const extension = fileName.slice(baseName.length); + + // Check if the base name follows camelCase + // camelCase: starts with lowercase letter, then letters/numbers only + const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/; + + if (!camelCaseRegex.test(baseName)) { + const suggested = toCamelCase(baseName) + extension; + + context.report({ + node, + messageId: 'invalidFileName', + data: { + fileName, + suggested, + }, + }); + } + }, + }; + }, +}; + +/** + * Convert a string to camelCase + * @param {string} str + * @returns {string} + */ +function toCamelCase(str) { + return str + .split(/[-_.\s]+/) + .map((word, index) => { + if (index === 0) { + return word.toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index fdfab80e2..e322ad8c4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,14 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import eslintConfigPrettier from 'eslint-config-prettier/flat'; import nodePlugin from 'eslint-plugin-n'; +import { fileNamingRule } from './eslint-rules/fileNaming.mjs'; + +// Local plugin for custom rules +const localPlugin = { + rules: { + 'file-naming': fileNamingRule, + }, +}; export default tseslint.config( eslint.configs.recommended, @@ -13,11 +21,18 @@ export default tseslint.config( reportUnusedDisableDirectives: false }, plugins: { - n: nodePlugin + n: nodePlugin, + local: localPlugin }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'n/prefer-node-protocol': 'error' + 'n/prefer-node-protocol': 'error', + 'local/file-naming': ['warn', { + ignore: [ + '^spec\\.types', + '^types\\.' + ] + }] } }, { diff --git a/package-lock.json b/package-lock.json index d551aa61d..6e6973031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1319,6 +1319,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.11.0", "@typescript-eslint/types": "8.11.0", @@ -1718,6 +1719,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2286,6 +2288,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -4272,6 +4275,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4363,6 +4367,7 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4408,6 +4413,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4609,6 +4615,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4622,6 +4629,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4774,6 +4782,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }