@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 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.7",
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
+ };