@dmitryrechkin/eslint-standard 1.5.7 → 1.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -10
- package/eslint.config.mjs +80 -0
- package/package.json +4 -3
- package/src/plugins/standard-conventions.mjs +605 -0
package/README.md
CHANGED
|
@@ -62,6 +62,65 @@ export default config();
|
|
|
62
62
|
- ✅ No console/debugging code
|
|
63
63
|
- ✅ Enhanced type safety rules
|
|
64
64
|
|
|
65
|
+
### Strict Mode
|
|
66
|
+
Enterprise-grade architectural conventions for large-scale TypeScript monorepos. Enforces consistent project structure, naming conventions, and design patterns.
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
import config from '@dmitryrechkin/eslint-standard';
|
|
70
|
+
|
|
71
|
+
export default config({
|
|
72
|
+
strict: true,
|
|
73
|
+
tsconfigPath: './tsconfig.json'
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Features:**
|
|
78
|
+
- ✅ Enforced folder structure (services/, repositories/, helpers/, etc.)
|
|
79
|
+
- ✅ Single Responsibility Principle for services and transformers
|
|
80
|
+
- ✅ CQRS pattern enforcement for repositories
|
|
81
|
+
- ✅ Static-only helpers, no static methods in other classes
|
|
82
|
+
- ✅ Consistent interface naming conventions
|
|
83
|
+
- ✅ One class per file
|
|
84
|
+
|
|
85
|
+
#### Strict Mode Rules
|
|
86
|
+
|
|
87
|
+
| Rule | Enforcement | Rationale |
|
|
88
|
+
|------|-------------|-----------|
|
|
89
|
+
| **Class Location** | `*Service` → `services/`, `*Repository` → `repositories/`, `*Helper` → `helpers/`, `*Factory` → `factories/`, `*Transformer` → `transformers/`, `*Registry` → `registries/`, `*Adapter` → `adapters/` | Predictable project structure enables faster navigation and onboarding |
|
|
90
|
+
| **Service Single Method** | Services must have only one public method | Single Responsibility Principle - each service does one thing well |
|
|
91
|
+
| **Transformer Single Method** | Transformers must have only one public method | Clear data transformation contracts |
|
|
92
|
+
| **Helper Static Only** | Helper classes must contain only static methods | Helpers are utility collections, not stateful objects |
|
|
93
|
+
| **No Static in Non-Helpers** | Non-helper/factory classes cannot have static methods | Prevents hidden global state, improves testability via dependency injection |
|
|
94
|
+
| **Type Location** | `interface TypeXXX` must be in `types/` folder | Centralized type definitions for reusability |
|
|
95
|
+
| **Interface Naming** | `TypeXXX` for data types, `XXXInterface` for class contracts | Clear distinction between data structures and behavioral contracts |
|
|
96
|
+
| **One Class Per File** | Each file can contain only one class | Simplifies imports, improves code organization |
|
|
97
|
+
| **Repository CQRS** | `CommandRepository` cannot have query methods (`get`, `find`, `list`), `QueryRepository` cannot have command methods (`create`, `update`, `delete`) | Command Query Responsibility Segregation for scalable data access |
|
|
98
|
+
| **Folder CamelCase** | All folder names must be camelCase | Consistent naming across the codebase |
|
|
99
|
+
| **Function Name Match Filename** | Top-level function name must match filename | Predictable imports and file discovery |
|
|
100
|
+
|
|
101
|
+
#### Example Project Structure
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
src/
|
|
105
|
+
├── services/
|
|
106
|
+
│ ├── UserService.ts # class UserService { execute() }
|
|
107
|
+
│ └── PaymentService.ts # class PaymentService { process() }
|
|
108
|
+
├── repositories/
|
|
109
|
+
│ ├── UserCommandRepository.ts # create(), update(), delete()
|
|
110
|
+
│ └── UserQueryRepository.ts # find(), getById(), list()
|
|
111
|
+
├── helpers/
|
|
112
|
+
│ └── ValidationHelper.ts # static validate(), static sanitize()
|
|
113
|
+
├── factories/
|
|
114
|
+
│ └── UserFactory.ts # static create()
|
|
115
|
+
├── transformers/
|
|
116
|
+
│ └── UserTransformer.ts # transform()
|
|
117
|
+
├── types/
|
|
118
|
+
│ ├── TypeUser.ts # interface TypeUser { id: string }
|
|
119
|
+
│ └── TypePayment.ts # interface TypePayment { amount: number }
|
|
120
|
+
└── adapters/
|
|
121
|
+
└── StripeAdapter.ts # class StripeAdapter
|
|
122
|
+
```
|
|
123
|
+
|
|
65
124
|
## ⚙️ Configuration Options
|
|
66
125
|
|
|
67
126
|
All configurations accept the same configuration options:
|
|
@@ -124,16 +183,22 @@ npx @dmitryrechkin/eslint-standard check-deps --install
|
|
|
124
183
|
|
|
125
184
|
## 📊 Preset Comparison
|
|
126
185
|
|
|
127
|
-
| Feature | Standard | Aggressive | Library |
|
|
128
|
-
|
|
129
|
-
| Unused imports cleanup | ✅ | ✅ | ✅ |
|
|
130
|
-
| Unused variables detection | Basic | Enhanced | Enhanced |
|
|
131
|
-
| Dead code detection | Basic | ✅ | ✅ |
|
|
132
|
-
| Unused exports check | ❌ | ✅ | Very Strict |
|
|
133
|
-
| JSDoc requirements | Basic | Basic | Strict |
|
|
134
|
-
| Console statements | Warning | Warning | Error |
|
|
135
|
-
| Return type hints | Error | Error | Explicit |
|
|
136
|
-
| Type safety | High | High | Very High |
|
|
186
|
+
| Feature | Standard | Aggressive | Library | Strict Mode |
|
|
187
|
+
|---------|----------|------------|---------|-------------|
|
|
188
|
+
| Unused imports cleanup | ✅ | ✅ | ✅ | ✅ |
|
|
189
|
+
| Unused variables detection | Basic | Enhanced | Enhanced | Basic |
|
|
190
|
+
| Dead code detection | Basic | ✅ | ✅ | Basic |
|
|
191
|
+
| Unused exports check | ❌ | ✅ | Very Strict | ❌ |
|
|
192
|
+
| JSDoc requirements | Basic | Basic | Strict | Basic |
|
|
193
|
+
| Console statements | Warning | Warning | Error | Warning |
|
|
194
|
+
| Return type hints | Error | Error | Explicit | Error |
|
|
195
|
+
| Type safety | High | High | Very High | High |
|
|
196
|
+
| Folder structure enforcement | ❌ | ❌ | ❌ | ✅ |
|
|
197
|
+
| Single Responsibility (Services) | ❌ | ❌ | ❌ | ✅ |
|
|
198
|
+
| CQRS Repository pattern | ❌ | ❌ | ❌ | ✅ |
|
|
199
|
+
| Interface naming conventions | ❌ | ❌ | ❌ | ✅ |
|
|
200
|
+
| One class per file | ❌ | ❌ | ❌ | ✅ |
|
|
201
|
+
| Static method restrictions | ❌ | ❌ | ❌ | ✅ |
|
|
137
202
|
|
|
138
203
|
## 📈 Industry Standards Comparison
|
|
139
204
|
|
|
@@ -198,6 +263,9 @@ Use **Library** preset - ensures clean public APIs and comprehensive documentati
|
|
|
198
263
|
### For Maximum Code Quality
|
|
199
264
|
Use **Aggressive** preset - comprehensive unused code detection.
|
|
200
265
|
|
|
266
|
+
### For Enterprise Monorepos
|
|
267
|
+
Use **Strict Mode** - enforces architectural conventions, folder structure, and design patterns for large teams.
|
|
268
|
+
|
|
201
269
|
## 🔍 Example Projects
|
|
202
270
|
|
|
203
271
|
### React Application
|
|
@@ -235,6 +303,18 @@ export default config({
|
|
|
235
303
|
});
|
|
236
304
|
```
|
|
237
305
|
|
|
306
|
+
### Enterprise Monorepo
|
|
307
|
+
```javascript
|
|
308
|
+
// eslint.config.mjs
|
|
309
|
+
import config from '@dmitryrechkin/eslint-standard';
|
|
310
|
+
|
|
311
|
+
export default config({
|
|
312
|
+
strict: true,
|
|
313
|
+
tsconfigPath: './tsconfig.json',
|
|
314
|
+
ignores: ['dist/**', 'node_modules/**']
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
238
318
|
## 🤝 Contributing
|
|
239
319
|
|
|
240
320
|
Issues and pull requests are welcome on [GitHub](https://github.com/dmitryrechkin/eslint-standard).
|
package/eslint.config.mjs
CHANGED
|
@@ -27,16 +27,19 @@ import unicornPlugin from 'eslint-plugin-unicorn';
|
|
|
27
27
|
import noSecretsPlugin from 'eslint-plugin-no-secrets';
|
|
28
28
|
import regexpPlugin from 'eslint-plugin-regexp';
|
|
29
29
|
import functionalPlugin from 'eslint-plugin-functional';
|
|
30
|
+
import standardConventionsPlugin from './src/plugins/standard-conventions.mjs';
|
|
30
31
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
31
32
|
import prettierConfig from 'eslint-config-prettier';
|
|
32
33
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
33
34
|
import prettierPlugin from 'eslint-plugin-prettier';
|
|
35
|
+
|
|
34
36
|
export default function ({
|
|
35
37
|
tsconfigPath = './tsconfig.json',
|
|
36
38
|
ignores = [],
|
|
37
39
|
files = [],
|
|
38
40
|
plugins = {},
|
|
39
41
|
rules = {},
|
|
42
|
+
strict = false,
|
|
40
43
|
prettierPlugin: externalPrettierPlugin = undefined
|
|
41
44
|
} = {}) {
|
|
42
45
|
// Use external prettier plugin if provided, otherwise fallback to bundled one
|
|
@@ -77,6 +80,7 @@ export default function ({
|
|
|
77
80
|
'no-secrets': noSecretsPlugin,
|
|
78
81
|
'regexp': regexpPlugin,
|
|
79
82
|
'functional': functionalPlugin,
|
|
83
|
+
'standard-conventions': standardConventionsPlugin,
|
|
80
84
|
'prettier': activePrettierPlugin,
|
|
81
85
|
...plugins,
|
|
82
86
|
},
|
|
@@ -99,6 +103,7 @@ export default function ({
|
|
|
99
103
|
|
|
100
104
|
// Original @dmitryrechkin/eslint-standard rules
|
|
101
105
|
'@typescript-eslint/explicit-function-return-type': 'error',
|
|
106
|
+
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
|
102
107
|
'@typescript-eslint/no-explicit-any': 'error', // Ban 'any' type for type safety
|
|
103
108
|
|
|
104
109
|
// Original coding guidelines - formatting rules disabled in favor of prettier
|
|
@@ -843,6 +848,81 @@ export default function ({
|
|
|
843
848
|
'unicorn/prefer-query-selector': 'off', // Allow different DOM query methods
|
|
844
849
|
'unicorn/prevent-abbreviations': 'off', // Allow abbreviations for domain-specific terms
|
|
845
850
|
|
|
851
|
+
...(strict ? {
|
|
852
|
+
// Standard conventions for services, transformers, and function naming
|
|
853
|
+
'standard-conventions/service-single-public-method': 'error',
|
|
854
|
+
'standard-conventions/transformer-single-public-method': 'error',
|
|
855
|
+
'standard-conventions/function-name-match-filename': 'error',
|
|
856
|
+
'standard-conventions/folder-camel-case': 'error',
|
|
857
|
+
|
|
858
|
+
// Helper static-only rule: Helpers MUST have only static methods
|
|
859
|
+
'standard-conventions/helper-static-only': 'error',
|
|
860
|
+
// Non-helper classes should NOT have static methods (except Factories)
|
|
861
|
+
'standard-conventions/no-static-in-non-helpers': 'error',
|
|
862
|
+
|
|
863
|
+
// Class location rules: enforce proper folder structure
|
|
864
|
+
'standard-conventions/class-location': ['error', {
|
|
865
|
+
mappings: {
|
|
866
|
+
Service: 'services',
|
|
867
|
+
Repository: 'repositories',
|
|
868
|
+
Helper: 'helpers',
|
|
869
|
+
Factory: 'factories',
|
|
870
|
+
Transformer: 'transformers',
|
|
871
|
+
Registry: 'registries',
|
|
872
|
+
Adapter: 'adapters'
|
|
873
|
+
}
|
|
874
|
+
}],
|
|
875
|
+
|
|
876
|
+
// Type location rule: Types must be in types folder
|
|
877
|
+
'standard-conventions/type-location': 'error',
|
|
878
|
+
|
|
879
|
+
// One class per file rule
|
|
880
|
+
'standard-conventions/one-class-per-file': 'error',
|
|
881
|
+
|
|
882
|
+
// Repository CQRS enforcement
|
|
883
|
+
'standard-conventions/repository-cqrs': 'error',
|
|
884
|
+
|
|
885
|
+
'unicorn/filename-case': ['error', {
|
|
886
|
+
cases: {
|
|
887
|
+
camelCase: true,
|
|
888
|
+
pascalCase: true
|
|
889
|
+
}
|
|
890
|
+
}],
|
|
891
|
+
// Enforce Type prefix OR Interface suffix strictly
|
|
892
|
+
// TypeXXX for data types, XXXInterface for class contracts
|
|
893
|
+
'@typescript-eslint/naming-convention': [
|
|
894
|
+
'error',
|
|
895
|
+
{
|
|
896
|
+
selector: 'interface',
|
|
897
|
+
format: ['PascalCase'],
|
|
898
|
+
custom: {
|
|
899
|
+
regex: '(^Type[A-Z]|Interface$)',
|
|
900
|
+
match: true
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
selector: 'typeAlias',
|
|
905
|
+
format: ['PascalCase'],
|
|
906
|
+
custom: {
|
|
907
|
+
regex: '^Type[A-Z]',
|
|
908
|
+
match: true
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
selector: 'class',
|
|
913
|
+
format: ['PascalCase']
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
selector: 'enum',
|
|
917
|
+
format: ['PascalCase'],
|
|
918
|
+
custom: {
|
|
919
|
+
regex: '^Enum[A-Z]',
|
|
920
|
+
match: true
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
]
|
|
924
|
+
} : {}),
|
|
925
|
+
|
|
846
926
|
// Allow custom rules to be added
|
|
847
927
|
...rules
|
|
848
928
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmitryrechkin/eslint-standard",
|
|
3
3
|
"description": "This package provides a shared ESLint configuration which includes TypeScript support and a set of specific linting rules designed to ensure high-quality and consistent code style across projects.",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.8",
|
|
5
5
|
"main": "eslint.config.mjs",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./eslint.config.mjs"
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"test:spacing": "node tests/test-spacing-rules.mjs",
|
|
32
32
|
"test:switch-case": "node tests/test-switch-case-simple.mjs",
|
|
33
33
|
"test:cli": "node tests/test-cli.mjs",
|
|
34
|
-
"test:install": "node tests/test-install-simulation.mjs"
|
|
34
|
+
"test:install": "node tests/test-install-simulation.mjs",
|
|
35
|
+
"test:strict": "node tests/test-strict-conventions.mjs"
|
|
35
36
|
},
|
|
36
37
|
"keywords": [],
|
|
37
38
|
"author": "",
|
|
@@ -65,4 +66,4 @@
|
|
|
65
66
|
"prettier": "^3.0.0",
|
|
66
67
|
"typescript": "^5.0.0"
|
|
67
68
|
}
|
|
68
|
-
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rule: Services must have only one public method
|
|
5
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
6
|
+
*/
|
|
7
|
+
const serviceSinglePublicMethodRule = {
|
|
8
|
+
meta: {
|
|
9
|
+
type: 'suggestion',
|
|
10
|
+
docs: {
|
|
11
|
+
description: 'Enforce that services have only one public method',
|
|
12
|
+
category: 'Best Practices',
|
|
13
|
+
recommended: false
|
|
14
|
+
},
|
|
15
|
+
schema: []
|
|
16
|
+
},
|
|
17
|
+
create(context) {
|
|
18
|
+
return {
|
|
19
|
+
ClassDeclaration(node) {
|
|
20
|
+
if (!node.id || !node.id.name.endsWith('Service')) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const publicMethods = node.body.body.filter(member => {
|
|
25
|
+
return (
|
|
26
|
+
member.type === 'MethodDefinition' &&
|
|
27
|
+
member.kind === 'method' &&
|
|
28
|
+
(member.accessibility === 'public' || !member.accessibility) &&
|
|
29
|
+
!member.static
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (publicMethods.length > 1) {
|
|
34
|
+
context.report({
|
|
35
|
+
node: node.id,
|
|
36
|
+
message: 'Service {{ name }} has {{ count }} public methods. Services should have only one public method.',
|
|
37
|
+
data: {
|
|
38
|
+
name: node.id.name,
|
|
39
|
+
count: publicMethods.length
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rule: Top-level function name must match filename
|
|
50
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
51
|
+
*/
|
|
52
|
+
const functionNameMatchFilenameRule = {
|
|
53
|
+
meta: {
|
|
54
|
+
type: 'suggestion',
|
|
55
|
+
docs: {
|
|
56
|
+
description: 'Enforce that top-level function name matches filename',
|
|
57
|
+
category: 'Best Practices',
|
|
58
|
+
recommended: false
|
|
59
|
+
},
|
|
60
|
+
schema: []
|
|
61
|
+
},
|
|
62
|
+
create(context) {
|
|
63
|
+
const filename = path.basename(context.getFilename(), path.extname(context.getFilename()));
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
FunctionDeclaration(node) {
|
|
67
|
+
// Only check top-level functions or functions inside exports
|
|
68
|
+
const isTopLevel = node.parent.type === 'Program' ||
|
|
69
|
+
(node.parent.type === 'ExportNamedDeclaration' && node.parent.parent.type === 'Program') ||
|
|
70
|
+
(node.parent.type === 'ExportDefaultDeclaration' && node.parent.parent.type === 'Program');
|
|
71
|
+
|
|
72
|
+
if (!isTopLevel) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (node.id && node.id.name !== filename) {
|
|
77
|
+
// Special case: ignore if filename is 'index'
|
|
78
|
+
if (filename === 'index') {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
context.report({
|
|
83
|
+
node: node.id,
|
|
84
|
+
message: 'Function name "{{ name }}" does not match filename "{{ filename }}".',
|
|
85
|
+
data: {
|
|
86
|
+
name: node.id.name,
|
|
87
|
+
filename: filename
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rule: Folder names must be camelCase
|
|
98
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
99
|
+
*/
|
|
100
|
+
const folderCamelCaseRule = {
|
|
101
|
+
meta: {
|
|
102
|
+
type: 'suggestion',
|
|
103
|
+
docs: {
|
|
104
|
+
description: 'Enforce that folder names are camelCase',
|
|
105
|
+
category: 'Best Practices',
|
|
106
|
+
recommended: false
|
|
107
|
+
},
|
|
108
|
+
schema: []
|
|
109
|
+
},
|
|
110
|
+
create(context) {
|
|
111
|
+
return {
|
|
112
|
+
Program() {
|
|
113
|
+
const fullPath = context.getFilename();
|
|
114
|
+
|
|
115
|
+
if (fullPath === '<input>' || fullPath === '<text>') {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const dirPath = path.dirname(fullPath);
|
|
120
|
+
const relativePath = path.relative(process.cwd(), dirPath);
|
|
121
|
+
|
|
122
|
+
if (!relativePath || relativePath === '.') {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const folders = relativePath.split(path.sep);
|
|
127
|
+
const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
|
|
128
|
+
|
|
129
|
+
for (const folder of folders) {
|
|
130
|
+
// Skip node_modules, dist, tests, and hidden folders
|
|
131
|
+
if (folder === 'node_modules' || folder === 'dist' || folder === 'tests' || folder.startsWith('.')) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Skip common top-level folders that might not be camelCase (optional, but good for compatibility)
|
|
136
|
+
if (['src', 'apps', 'packages', 'tools', 'docs', 'config'].includes(folder)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!camelCaseRegex.test(folder)) {
|
|
141
|
+
context.report({
|
|
142
|
+
loc: { line: 1, column: 0 },
|
|
143
|
+
message: 'Folder name "{{ folder }}" should be camelCase.',
|
|
144
|
+
data: { folder }
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Rule: Helpers must have only static methods
|
|
155
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
156
|
+
*/
|
|
157
|
+
const helperStaticOnlyRule = {
|
|
158
|
+
meta: {
|
|
159
|
+
type: 'problem',
|
|
160
|
+
docs: {
|
|
161
|
+
description: 'Enforce that Helper classes only contain static methods',
|
|
162
|
+
category: 'Best Practices',
|
|
163
|
+
recommended: false
|
|
164
|
+
},
|
|
165
|
+
schema: []
|
|
166
|
+
},
|
|
167
|
+
create(context) {
|
|
168
|
+
return {
|
|
169
|
+
ClassDeclaration(node) {
|
|
170
|
+
if (!node.id || !node.id.name.endsWith('Helper')) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const nonStaticMembers = node.body.body.filter(member => {
|
|
175
|
+
// Skip constructors
|
|
176
|
+
if (member.type === 'MethodDefinition' && member.kind === 'constructor') {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check for non-static methods and properties
|
|
181
|
+
return (
|
|
182
|
+
(member.type === 'MethodDefinition' || member.type === 'PropertyDefinition') &&
|
|
183
|
+
!member.static
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (nonStaticMembers.length > 0) {
|
|
188
|
+
for (const member of nonStaticMembers) {
|
|
189
|
+
const memberName = member.key?.name || 'unknown';
|
|
190
|
+
|
|
191
|
+
context.report({
|
|
192
|
+
node: member,
|
|
193
|
+
message: 'Helper class "{{ className }}" should only have static members. Member "{{ memberName }}" is not static.',
|
|
194
|
+
data: {
|
|
195
|
+
className: node.id.name,
|
|
196
|
+
memberName
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Rule: Non-helper classes should not have static methods (except factories)
|
|
208
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
209
|
+
*/
|
|
210
|
+
const noStaticInNonHelpersRule = {
|
|
211
|
+
meta: {
|
|
212
|
+
type: 'suggestion',
|
|
213
|
+
docs: {
|
|
214
|
+
description: 'Enforce that non-Helper/Factory classes do not have static methods',
|
|
215
|
+
category: 'Best Practices',
|
|
216
|
+
recommended: false
|
|
217
|
+
},
|
|
218
|
+
schema: []
|
|
219
|
+
},
|
|
220
|
+
create(context) {
|
|
221
|
+
return {
|
|
222
|
+
ClassDeclaration(node) {
|
|
223
|
+
if (!node.id) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const className = node.id.name;
|
|
228
|
+
|
|
229
|
+
// Allow static methods in Helpers and Factories
|
|
230
|
+
if (className.endsWith('Helper') || className.endsWith('Factory')) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const staticMembers = node.body.body.filter(member => {
|
|
235
|
+
return (
|
|
236
|
+
(member.type === 'MethodDefinition' || member.type === 'PropertyDefinition') &&
|
|
237
|
+
member.static
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (staticMembers.length > 0) {
|
|
242
|
+
for (const member of staticMembers) {
|
|
243
|
+
const memberName = member.key?.name || 'unknown';
|
|
244
|
+
|
|
245
|
+
context.report({
|
|
246
|
+
node: member,
|
|
247
|
+
message: 'Class "{{ className }}" should not have static members. Use a Helper class for static methods. Static member: "{{ memberName }}".',
|
|
248
|
+
data: {
|
|
249
|
+
className,
|
|
250
|
+
memberName
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Rule: Classes must be in appropriate folders based on their suffix
|
|
262
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
263
|
+
*/
|
|
264
|
+
const classLocationRule = {
|
|
265
|
+
meta: {
|
|
266
|
+
type: 'suggestion',
|
|
267
|
+
docs: {
|
|
268
|
+
description: 'Enforce that classes are located in appropriate folders based on their suffix',
|
|
269
|
+
category: 'Best Practices',
|
|
270
|
+
recommended: false
|
|
271
|
+
},
|
|
272
|
+
schema: [
|
|
273
|
+
{
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
mappings: {
|
|
277
|
+
type: 'object',
|
|
278
|
+
additionalProperties: {
|
|
279
|
+
type: 'string'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
additionalProperties: false
|
|
284
|
+
}
|
|
285
|
+
]
|
|
286
|
+
},
|
|
287
|
+
create(context) {
|
|
288
|
+
const options = context.options[0] || {};
|
|
289
|
+
const defaultMappings = {
|
|
290
|
+
Service: 'services',
|
|
291
|
+
Repository: 'repositories',
|
|
292
|
+
Helper: 'helpers',
|
|
293
|
+
Factory: 'factories',
|
|
294
|
+
Transformer: 'transformers',
|
|
295
|
+
Registry: 'registries',
|
|
296
|
+
Adapter: 'adapters'
|
|
297
|
+
};
|
|
298
|
+
const mappings = { ...defaultMappings, ...options.mappings };
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
ClassDeclaration(node) {
|
|
302
|
+
if (!node.id) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const className = node.id.name;
|
|
307
|
+
const fullPath = context.getFilename();
|
|
308
|
+
|
|
309
|
+
if (fullPath === '<input>' || fullPath === '<text>') {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const dirPath = path.dirname(fullPath);
|
|
314
|
+
|
|
315
|
+
for (const [suffix, expectedFolder] of Object.entries(mappings)) {
|
|
316
|
+
if (className.endsWith(suffix)) {
|
|
317
|
+
// Check if the file is in the expected folder or a subfolder
|
|
318
|
+
const pathParts = dirPath.split(path.sep);
|
|
319
|
+
|
|
320
|
+
if (!pathParts.includes(expectedFolder)) {
|
|
321
|
+
context.report({
|
|
322
|
+
node: node.id,
|
|
323
|
+
message: 'Class "{{ className }}" with suffix "{{ suffix }}" should be in a "{{ expectedFolder }}" folder.',
|
|
324
|
+
data: {
|
|
325
|
+
className,
|
|
326
|
+
suffix,
|
|
327
|
+
expectedFolder
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
break; // Only check first matching suffix
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Rule: Interface files with TypeXXX must be in types folder
|
|
342
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
343
|
+
*/
|
|
344
|
+
const typeLocationRule = {
|
|
345
|
+
meta: {
|
|
346
|
+
type: 'suggestion',
|
|
347
|
+
docs: {
|
|
348
|
+
description: 'Enforce that Type interfaces are located in types folder',
|
|
349
|
+
category: 'Best Practices',
|
|
350
|
+
recommended: false
|
|
351
|
+
},
|
|
352
|
+
schema: []
|
|
353
|
+
},
|
|
354
|
+
create(context) {
|
|
355
|
+
return {
|
|
356
|
+
TSInterfaceDeclaration(node) {
|
|
357
|
+
if (!node.id) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const interfaceName = node.id.name;
|
|
362
|
+
|
|
363
|
+
// Check if it's a Type interface (starts with Type)
|
|
364
|
+
if (!interfaceName.startsWith('Type')) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const fullPath = context.getFilename();
|
|
369
|
+
|
|
370
|
+
if (fullPath === '<input>' || fullPath === '<text>') {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const dirPath = path.dirname(fullPath);
|
|
375
|
+
const pathParts = dirPath.split(path.sep);
|
|
376
|
+
|
|
377
|
+
if (!pathParts.includes('types')) {
|
|
378
|
+
context.report({
|
|
379
|
+
node: node.id,
|
|
380
|
+
message: 'Type interface "{{ interfaceName }}" should be in a "types" folder.',
|
|
381
|
+
data: { interfaceName }
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
TSTypeAliasDeclaration(node) {
|
|
386
|
+
if (!node.id) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const typeName = node.id.name;
|
|
391
|
+
|
|
392
|
+
// Check if it's a Type alias (starts with Type)
|
|
393
|
+
if (!typeName.startsWith('Type')) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const fullPath = context.getFilename();
|
|
398
|
+
|
|
399
|
+
if (fullPath === '<input>' || fullPath === '<text>') {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const dirPath = path.dirname(fullPath);
|
|
404
|
+
const pathParts = dirPath.split(path.sep);
|
|
405
|
+
|
|
406
|
+
if (!pathParts.includes('types')) {
|
|
407
|
+
context.report({
|
|
408
|
+
node: node.id,
|
|
409
|
+
message: 'Type alias "{{ typeName }}" should be in a "types" folder.',
|
|
410
|
+
data: { typeName }
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Rule: Transformers must have single public method
|
|
420
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
421
|
+
*/
|
|
422
|
+
const transformerSinglePublicMethodRule = {
|
|
423
|
+
meta: {
|
|
424
|
+
type: 'suggestion',
|
|
425
|
+
docs: {
|
|
426
|
+
description: 'Enforce that transformers have only one public method',
|
|
427
|
+
category: 'Best Practices',
|
|
428
|
+
recommended: false
|
|
429
|
+
},
|
|
430
|
+
schema: []
|
|
431
|
+
},
|
|
432
|
+
create(context) {
|
|
433
|
+
return {
|
|
434
|
+
ClassDeclaration(node) {
|
|
435
|
+
if (!node.id || !node.id.name.endsWith('Transformer')) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const publicMethods = node.body.body.filter(member => {
|
|
440
|
+
return (
|
|
441
|
+
member.type === 'MethodDefinition' &&
|
|
442
|
+
member.kind === 'method' &&
|
|
443
|
+
(member.accessibility === 'public' || !member.accessibility) &&
|
|
444
|
+
!member.static
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (publicMethods.length > 1) {
|
|
449
|
+
context.report({
|
|
450
|
+
node: node.id,
|
|
451
|
+
message: 'Transformer {{ name }} has {{ count }} public methods. Transformers should have only one public method.',
|
|
452
|
+
data: {
|
|
453
|
+
name: node.id.name,
|
|
454
|
+
count: publicMethods.length
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Rule: Only one class per file
|
|
465
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
466
|
+
*/
|
|
467
|
+
const oneClassPerFileRule = {
|
|
468
|
+
meta: {
|
|
469
|
+
type: 'suggestion',
|
|
470
|
+
docs: {
|
|
471
|
+
description: 'Enforce that each file contains only one class',
|
|
472
|
+
category: 'Best Practices',
|
|
473
|
+
recommended: false
|
|
474
|
+
},
|
|
475
|
+
schema: []
|
|
476
|
+
},
|
|
477
|
+
create(context) {
|
|
478
|
+
const classes = [];
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
ClassDeclaration(node) {
|
|
482
|
+
if (node.id) {
|
|
483
|
+
classes.push(node);
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
'Program:exit'() {
|
|
487
|
+
if (classes.length > 1) {
|
|
488
|
+
// Report on all classes except the first one
|
|
489
|
+
for (let idx = 1; idx < classes.length; idx++) {
|
|
490
|
+
context.report({
|
|
491
|
+
node: classes[idx].id,
|
|
492
|
+
message: 'File contains multiple classes. Each class should be in its own file. Found {{ count }} classes.',
|
|
493
|
+
data: {
|
|
494
|
+
count: classes.length
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Rule: Repository CQRS method naming
|
|
506
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
507
|
+
*/
|
|
508
|
+
const repositoryCqrsRule = {
|
|
509
|
+
meta: {
|
|
510
|
+
type: 'suggestion',
|
|
511
|
+
docs: {
|
|
512
|
+
description: 'Enforce CQRS naming for repository classes (CommandRepository vs QueryRepository)',
|
|
513
|
+
category: 'Best Practices',
|
|
514
|
+
recommended: false
|
|
515
|
+
},
|
|
516
|
+
schema: []
|
|
517
|
+
},
|
|
518
|
+
create(context) {
|
|
519
|
+
const commandMethods = ['create', 'update', 'delete', 'save', 'insert', 'remove', 'add', 'set'];
|
|
520
|
+
const queryMethods = ['get', 'find', 'fetch', 'list', 'search', 'query', 'read', 'load', 'retrieve'];
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
ClassDeclaration(node) {
|
|
524
|
+
if (!node.id) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const className = node.id.name;
|
|
529
|
+
|
|
530
|
+
// Only check Repository classes
|
|
531
|
+
if (!className.endsWith('Repository')) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const isCommandRepository = className.includes('Command');
|
|
536
|
+
const isQueryRepository = className.includes('Query');
|
|
537
|
+
|
|
538
|
+
// If not explicitly typed, skip this check (backward compatibility)
|
|
539
|
+
if (!isCommandRepository && !isQueryRepository) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const publicMethods = node.body.body.filter(member => {
|
|
544
|
+
return (
|
|
545
|
+
member.type === 'MethodDefinition' &&
|
|
546
|
+
member.kind === 'method' &&
|
|
547
|
+
(member.accessibility === 'public' || !member.accessibility) &&
|
|
548
|
+
!member.static &&
|
|
549
|
+
member.key?.name
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
for (const method of publicMethods) {
|
|
554
|
+
const methodName = method.key.name.toLowerCase();
|
|
555
|
+
|
|
556
|
+
if (isCommandRepository) {
|
|
557
|
+
// Command repositories should not have query methods
|
|
558
|
+
const hasQueryMethod = queryMethods.some(queryMethod => methodName.startsWith(queryMethod));
|
|
559
|
+
|
|
560
|
+
if (hasQueryMethod) {
|
|
561
|
+
context.report({
|
|
562
|
+
node: method.key,
|
|
563
|
+
message: 'CommandRepository "{{ className }}" should not have query method "{{ methodName }}". Use a QueryRepository for read operations.',
|
|
564
|
+
data: {
|
|
565
|
+
className,
|
|
566
|
+
methodName: method.key.name
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else if (isQueryRepository) {
|
|
572
|
+
// Query repositories should not have command methods
|
|
573
|
+
const hasCommandMethod = commandMethods.some(cmdMethod => methodName.startsWith(cmdMethod));
|
|
574
|
+
|
|
575
|
+
if (hasCommandMethod) {
|
|
576
|
+
context.report({
|
|
577
|
+
node: method.key,
|
|
578
|
+
message: 'QueryRepository "{{ className }}" should not have command method "{{ methodName }}". Use a CommandRepository for write operations.',
|
|
579
|
+
data: {
|
|
580
|
+
className,
|
|
581
|
+
methodName: method.key.name
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
export default {
|
|
593
|
+
rules: {
|
|
594
|
+
'service-single-public-method': serviceSinglePublicMethodRule,
|
|
595
|
+
'function-name-match-filename': functionNameMatchFilenameRule,
|
|
596
|
+
'folder-camel-case': folderCamelCaseRule,
|
|
597
|
+
'helper-static-only': helperStaticOnlyRule,
|
|
598
|
+
'no-static-in-non-helpers': noStaticInNonHelpersRule,
|
|
599
|
+
'class-location': classLocationRule,
|
|
600
|
+
'type-location': typeLocationRule,
|
|
601
|
+
'transformer-single-public-method': transformerSinglePublicMethodRule,
|
|
602
|
+
'one-class-per-file': oneClassPerFileRule,
|
|
603
|
+
'repository-cqrs': repositoryCqrsRule
|
|
604
|
+
}
|
|
605
|
+
};
|