├── .node-version ├── .gitignore ├── rules ├── tsconfig.json ├── rule.js ├── tests.js ├── unified-filename-rules.js ├── unified-filename-rules.test.js ├── enforce-prop-decorator-enum.test.js └── enforce-prop-decorator-enum.js ├── .vscode └── launch.json ├── .github └── workflows │ └── node.js.yml └── package.json /.node-version: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /rules/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "allowJs": true 5 | }, 6 | "include": ["**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /rules/rule.js: -------------------------------------------------------------------------------- 1 | const { ESLintUtils } = require('@typescript-eslint/utils'); 2 | 3 | const createRule = ESLintUtils.RuleCreator( 4 | (name) => `https://example.com/rule/${name}` 5 | ); 6 | 7 | module.exports = { 8 | createRule, 9 | }; 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Mocha (Test single file)", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "${workspaceRoot}/node_modules/.bin/mocha", 13 | "--inspect-brk", 14 | "${relativeFile}" 15 | ], 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen", 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version-file: .node-version 20 | cache: 'npm' 21 | - run: npm ci 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Custom ESLint plugin", 3 | "main": "index.js", 4 | "scripts": { 5 | "test": "mocha 'rules/**/*.test.js'" 6 | }, 7 | "keywords": [ 8 | "eslint", 9 | "plugin", 10 | "custom" 11 | ], 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "eslint": "^9.10.0", 16 | "eslint-plugin-prettier": "^5.2.1", 17 | "prettier": "^3.3.3" 18 | }, 19 | "devDependencies": { 20 | "@typescript-eslint/parser": "^8.15.0", 21 | "@typescript-eslint/rule-tester": "^8.15.0", 22 | "@typescript-eslint/utils": "^8.15.0", 23 | "mocha": "^10.7.3", 24 | "typescript": "5.5.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /rules/tests.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { RuleTester } = require("@typescript-eslint/rule-tester"); 3 | // const parser = require("@typescript-eslint/parser"); 4 | const mocha = require("mocha"); 5 | 6 | RuleTester.afterAll = mocha.after; 7 | 8 | const ruleTester = new RuleTester({ 9 | languageOptions: { 10 | // parser, 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | projectService: { 16 | allowDefaultProject: ["*.ts*", "*.js*"], 17 | }, 18 | tsconfigRootDir: __dirname, 19 | }, 20 | }, 21 | }); 22 | 23 | module.exports = { 24 | /** 25 | * @type {RuleTester['run']} 26 | */ 27 | run: (...args) => ruleTester.run(...args), 28 | }; 29 | -------------------------------------------------------------------------------- /rules/unified-filename-rules.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { createRule } = require("./rule"); 3 | 4 | const constsFileNames = [".const.ts", ".consts.ts", ".constants.ts"]; 5 | 6 | const notAllowedStylesFileNames = [ 7 | "styles.ts", 8 | "styles.tsx", 9 | "style.ts", 10 | "style.tsx", 11 | ]; 12 | 13 | module.exports = createRule({ 14 | name: "unified-filename-rules", 15 | meta: { 16 | type: "problem", 17 | docs: { 18 | description: 19 | "Unified rule for enforcing specific naming conventions for various file types", 20 | category: "Best Practices", 21 | recommended: false, 22 | }, 23 | messages: { 24 | noActionFilename: 25 | "This name shows a `redux` icon using atom material icons due to convention with ...`Action`.", 26 | constsFilename: "No need for prefix of the component name imo", 27 | typesFilename: 28 | "To save time, you don’t need to name the file like this, just types.ts", 29 | stylesFilename: 30 | "file name is not styles.ts. by convention Componentname.styles.ts.", 31 | }, 32 | schema: [], // no options 33 | }, 34 | create(context) { 35 | const filename = context.getFilename(); 36 | const basename = path.basename(filename); 37 | 38 | if (filename.endsWith("Action.tsx")) { 39 | context.report({ 40 | messageId: "noActionFilename", 41 | loc: { line: 1, column: 0 }, 42 | }); 43 | } 44 | 45 | if (constsFileNames.some((name) => basename.endsWith(name))) { 46 | context.report({ 47 | messageId: "constsFilename", 48 | loc: { line: 1, column: 0 }, 49 | }); 50 | } 51 | 52 | if (basename.endsWith(".types.ts")) { 53 | context.report({ 54 | messageId: "typesFilename", 55 | loc: { line: 1, column: 0 }, 56 | }); 57 | } 58 | 59 | if ( 60 | notAllowedStylesFileNames.some((name) => basename.toLowerCase() === name) 61 | ) { 62 | context.report({ 63 | messageId: "stylesFilename", 64 | loc: { line: 1, column: 0 }, 65 | }); 66 | } 67 | 68 | if (basename.endsWith(".styles.ts") && !basename.match(/^[A-Z]/)) { 69 | context.report({ 70 | messageId: "stylesFilename", 71 | loc: { line: 1, column: 0 }, 72 | }); 73 | } 74 | 75 | return {}; 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /rules/unified-filename-rules.test.js: -------------------------------------------------------------------------------- 1 | const rule = require('./unified-filename-rules'); 2 | const { run } = require('./tests'); 3 | 4 | run('unified-filename-rules', rule, { 5 | valid: [ 6 | { 7 | filename: 'types.ts', 8 | code: 'type MyType = {};', 9 | }, 10 | { 11 | filename: 'some-other-file.ts', 12 | code: 'type MyType = {};', 13 | }, 14 | { 15 | code: '', 16 | filename: 'SomeComponent.tsx', 17 | }, 18 | { 19 | filename: 'consts.ts', 20 | code: 'const myConst = 1;', 21 | }, 22 | { 23 | filename: 'constants.ts', 24 | code: 'const myConst = 1;', 25 | }, 26 | { 27 | filename: 'const.ts', 28 | code: 'const myConst = 1;', 29 | }, 30 | { 31 | filename: 'UserInfoDialog.styles.ts', 32 | code: 'const myConst = 1;', 33 | }, 34 | ], 35 | invalid: [ 36 | { 37 | filename: 'PermissionWithColor.types.ts', 38 | code: 'type MyType = {};', 39 | errors: [ 40 | { 41 | message: 42 | 'To save time, you don’t need to name the file like this, just types.ts', 43 | }, 44 | ], 45 | }, 46 | { 47 | filename: 'AnotherExample.types.ts', 48 | code: 'type AnotherType = {};', 49 | errors: [ 50 | { 51 | message: 52 | 'To save time, you don’t need to name the file like this, just types.ts', 53 | }, 54 | ], 55 | }, 56 | { 57 | code: '', 58 | filename: 'SomeAction.tsx', 59 | errors: [{ messageId: 'noActionFilename' }], 60 | }, 61 | { 62 | filename: 'ComponentName.consts.ts', 63 | code: 'const myConst = 1;', 64 | errors: [{ messageId: 'constsFilename' }], 65 | }, 66 | { 67 | filename: 'ComponentName.constants.ts', 68 | code: 'const myConst = 1;', 69 | errors: [{ messageId: 'constsFilename' }], 70 | }, 71 | { 72 | filename: 'communities-permissions.const.ts', 73 | code: 'const myConst = 1;', 74 | errors: [{ messageId: 'constsFilename' }], 75 | }, 76 | { 77 | filename: 'style.ts', 78 | code: 'const myConst = 1;', 79 | errors: [{ messageId: 'stylesFilename' }], 80 | }, 81 | { 82 | filename: 'styles.ts', 83 | code: 'const myConst = 1;', 84 | errors: [{ messageId: 'stylesFilename' }], 85 | }, 86 | { 87 | filename: 'styles.tsx', 88 | code: 'const myConst = 1;', 89 | errors: [{ messageId: 'stylesFilename' }], 90 | }, 91 | { 92 | filename: 'component.styles.ts', 93 | code: 'const myConst = 1;', 94 | errors: [{ messageId: 'stylesFilename' }], 95 | }, 96 | ], 97 | }); 98 | -------------------------------------------------------------------------------- /rules/enforce-prop-decorator-enum.test.js: -------------------------------------------------------------------------------- 1 | const rule = require('./enforce-prop-decorator-enum'); 2 | const { run } = require('./tests'); 3 | 4 | run('enforce-prop-decorator-enum', rule, { 5 | valid: [ 6 | { 7 | code: ` 8 | enum OrganizationActionEnum { 9 | ACTION_ONE, 10 | ACTION_TWO, 11 | } 12 | class Permissions { 13 | @Prop({ type: String, enum: OrganizationActionEnum }) 14 | organization?: OrganizationActionEnum; 15 | } 16 | `, 17 | }, 18 | { 19 | code: ` 20 | enum OrganizationActionEnum { 21 | ACTION_ONE, 22 | ACTION_TWO, 23 | } 24 | class Permissions { 25 | @Prop({ type: [String], enum: OrganizationActionEnum }) 26 | organization?: OrganizationActionEnum[]; 27 | } 28 | `, 29 | }, 30 | ], 31 | invalid: [ 32 | { 33 | code: ` 34 | enum OrganizationActionEnum { 35 | ACTION_ONE, 36 | ACTION_TWO, 37 | } 38 | class Permissions { 39 | @Prop({ enum: OrganizationActionEnum }) 40 | organization?: OrganizationActionEnum; 41 | } 42 | `, 43 | errors: [ 44 | { 45 | messageId: 'missingType', 46 | data: { propertyName: 'organization' }, 47 | }, 48 | ], 49 | }, 50 | { 51 | code: ` 52 | enum OrganizationActionEnum { 53 | ACTION_ONE, 54 | ACTION_TWO, 55 | } 56 | class Permissions { 57 | @Prop({ type: [String], enum: [OrganizationActionEnum] }) 58 | organization?: OrganizationActionEnum[]; 59 | } 60 | `, 61 | errors: [ 62 | { 63 | messageId: 'incorrectEnum', 64 | data: { 65 | expectedEnum: 'OrganizationActionEnum', 66 | propertyName: 'organization', 67 | }, 68 | }, 69 | ], 70 | }, 71 | { 72 | code: ` 73 | enum OrganizationActionEnum { 74 | ACTION_ONE, 75 | ACTION_TWO, 76 | } 77 | class Permissions { 78 | @Prop({ type: [String] }) 79 | organization?: OrganizationActionEnum[]; 80 | } 81 | `, 82 | errors: [ 83 | { 84 | messageId: 'missingEnum', 85 | data: { 86 | propertyName: 'organization', 87 | }, 88 | }, 89 | ], 90 | }, 91 | { 92 | code: ` 93 | enum OrganizationActionEnum { 94 | ACTION_ONE, 95 | ACTION_TWO, 96 | } 97 | class Permissions { 98 | @Prop({ type: String, enum: OrganizationActionEnum }) 99 | organization?: OrganizationActionEnum[]; 100 | } 101 | `, 102 | errors: [ 103 | { 104 | messageId: 'incorrectType', 105 | data: { 106 | expectedType: '[String]', 107 | propertyName: 'organization', 108 | }, 109 | }, 110 | ], 111 | }, 112 | { 113 | code: ` 114 | enum ActionEnum { 115 | ACTION_ONE, 116 | ACTION_TWO, 117 | } 118 | class Permissions { 119 | @Prop({ type: [String], enum: ActionEnum }) 120 | organization?: ActionEnum; 121 | } 122 | `, 123 | errors: [ 124 | { 125 | messageId: 'incorrectType', 126 | data: { 127 | expectedType: 'String', 128 | propertyName: 'organization', 129 | }, 130 | }, 131 | ], 132 | }, 133 | { 134 | code: ` 135 | enum ActionEnum { 136 | ACTION_ONE, 137 | ACTION_TWO, 138 | } 139 | class Permissions { 140 | @Prop() 141 | organization?: ActionEnum; 142 | } 143 | `, 144 | errors: [ 145 | { 146 | messageId: 'missingType', 147 | data: { propertyName: 'organization' }, 148 | }, 149 | { 150 | messageId: 'missingEnum', 151 | data: { propertyName: 'organization' }, 152 | }, 153 | ], 154 | }, 155 | ], 156 | }); 157 | -------------------------------------------------------------------------------- /rules/enforce-prop-decorator-enum.js: -------------------------------------------------------------------------------- 1 | const { ESLintUtils } = require("@typescript-eslint/utils"); 2 | const { createRule } = require("./rule"); 3 | 4 | module.exports = createRule({ 5 | name: "enforce-prop-decorator-enum", 6 | meta: { 7 | type: "problem", 8 | docs: { 9 | description: 10 | 'Ensure @Prop decorator uses correct type and enum for enum properties, and always specify "type"', 11 | category: "Best Practices", 12 | recommended: false, 13 | }, 14 | messages: { 15 | missingType: 16 | '@Prop decorator is missing "type" for property "{{propertyName}}"', 17 | incorrectType: 18 | '@Prop "type" should be {{expectedType}} for property "{{propertyName}}"', 19 | missingEnum: 20 | '@Prop decorator is missing "enum" for property "{{propertyName}}"', 21 | incorrectEnum: 22 | '@Prop "enum" should be {{expectedEnum}} for property "{{propertyName}}"', 23 | }, 24 | schema: [], 25 | }, 26 | create(context) { 27 | const services = ESLintUtils.getParserServices(context); 28 | if (!services || !services.program || !services.esTreeNodeToTSNodeMap) { 29 | return {}; 30 | } 31 | const checker = services.program.getTypeChecker(); 32 | 33 | return { 34 | ClassProperty(node) { 35 | if (!node.decorators) return; 36 | 37 | const propDecorator = node.decorators.find( 38 | (decorator) => 39 | decorator.expression.type === "CallExpression" && 40 | decorator.expression.callee.name === "Prop" 41 | ); 42 | 43 | if (!propDecorator) return; 44 | 45 | const tsNode = services.esTreeNodeToTSNodeMap.get(node); 46 | const type = checker.getTypeAtLocation(tsNode); 47 | const propertyName = node.key.name; 48 | 49 | const isEnumType = (type) => { 50 | const symbol = type.getSymbol(); 51 | return ( 52 | symbol && 53 | (symbol.flags & ts.SymbolFlags.Enum || 54 | symbol.flags & ts.SymbolFlags.EnumLiteral) 55 | ); 56 | }; 57 | 58 | const isArrayType = (type) => { 59 | return checker.isArrayType(type); 60 | }; 61 | 62 | let isEnum = false; 63 | let isEnumArray = false; 64 | let enumTypeName = null; 65 | 66 | if (isEnumType(type)) { 67 | isEnum = true; 68 | enumTypeName = type.symbol.name; 69 | } else if (isArrayType(type)) { 70 | const [elementType] = checker.getTypeArguments(type); 71 | if (elementType && isEnumType(elementType)) { 72 | isEnumArray = true; 73 | enumTypeName = elementType.symbol.name; 74 | } 75 | } 76 | 77 | if (isEnum || isEnumArray) { 78 | const expectedType = isEnum ? "String" : "[String]"; 79 | const propOptionsArg = propDecorator.expression.arguments[0]; 80 | 81 | if (!propOptionsArg || propOptionsArg.type !== "ObjectExpression") { 82 | context.report({ 83 | node: propOptionsArg || propDecorator, 84 | messageId: "missingType", 85 | data: { propertyName }, 86 | }); 87 | context.report({ 88 | node: propOptionsArg || propDecorator, 89 | messageId: "missingEnum", 90 | data: { propertyName }, 91 | }); 92 | return; 93 | } 94 | 95 | const properties = propOptionsArg.properties; 96 | const typeProp = properties.find((p) => p.key.name === "type"); 97 | const enumProp = properties.find((p) => p.key.name === "enum"); 98 | 99 | if (!typeProp) { 100 | context.report({ 101 | node: propOptionsArg, 102 | messageId: "missingType", 103 | data: { propertyName }, 104 | }); 105 | } else { 106 | let typeValid = false; 107 | if (isEnum) { 108 | if ( 109 | typeProp.value.type === "Identifier" && 110 | typeProp.value.name === "String" 111 | ) { 112 | typeValid = true; 113 | } 114 | } else if (isEnumArray) { 115 | if ( 116 | typeProp.value.type === "ArrayExpression" && 117 | typeProp.value.elements.length === 1 && 118 | typeProp.value.elements[0].type === "Identifier" && 119 | typeProp.value.elements[0].name === "String" 120 | ) { 121 | typeValid = true; 122 | } 123 | } 124 | if (!typeValid) { 125 | context.report({ 126 | node: typeProp.value, 127 | messageId: "incorrectType", 128 | data: { expectedType, propertyName }, 129 | }); 130 | } 131 | } 132 | 133 | if (!enumProp) { 134 | context.report({ 135 | node: propOptionsArg, 136 | messageId: "missingEnum", 137 | data: { propertyName }, 138 | }); 139 | } else { 140 | let enumValid = false; 141 | if ( 142 | enumProp.value.type === "Identifier" && 143 | enumProp.value.name === enumTypeName 144 | ) { 145 | enumValid = true; 146 | } 147 | 148 | if (!enumValid) { 149 | context.report({ 150 | node: enumProp.value, 151 | messageId: "incorrectEnum", 152 | data: { expectedEnum: enumTypeName, propertyName }, 153 | }); 154 | } 155 | } 156 | } 157 | }, 158 | }; 159 | }, 160 | }); 161 | --------------------------------------------------------------------------------