@dmitryrechkin/eslint-standard 1.5.7 → 1.5.9

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.
@@ -0,0 +1,1156 @@
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
+ /**
50
+ * Rule: Factories must have only one public method
51
+ * @type {import('eslint').Rule.RuleModule}
52
+ */
53
+ const factorySinglePublicMethodRule = {
54
+ meta: {
55
+ type: 'suggestion',
56
+ docs: {
57
+ description: 'Enforce that factories have only one public method',
58
+ category: 'Best Practices',
59
+ recommended: false
60
+ },
61
+ schema: []
62
+ },
63
+ create(context) {
64
+ return {
65
+ ClassDeclaration(node) {
66
+ if (!node.id || !node.id.name.endsWith('Factory')) {
67
+ return;
68
+ }
69
+
70
+ const publicMethods = node.body.body.filter(member => {
71
+ return (
72
+ member.type === 'MethodDefinition' &&
73
+ member.kind === 'method' &&
74
+ (member.accessibility === 'public' || !member.accessibility) &&
75
+ !member.static
76
+ );
77
+ });
78
+
79
+ if (publicMethods.length > 1) {
80
+ context.report({
81
+ node: node.id,
82
+ message: 'Factory {{ name }} has {{ count }} public methods. Factories should have only one public method.',
83
+ data: {
84
+ name: node.id.name,
85
+ count: publicMethods.length
86
+ }
87
+ });
88
+ }
89
+ }
90
+ };
91
+ }
92
+ };
93
+
94
+
95
+ /**
96
+ * Rule: Top-level function name must match filename
97
+ * @type {import('eslint').Rule.RuleModule}
98
+ */
99
+ const functionNameMatchFilenameRule = {
100
+ meta: {
101
+ type: 'suggestion',
102
+ docs: {
103
+ description: 'Enforce that top-level function name matches filename',
104
+ category: 'Best Practices',
105
+ recommended: false
106
+ },
107
+ schema: []
108
+ },
109
+ create(context) {
110
+ const filename = path.basename(context.getFilename(), path.extname(context.getFilename()));
111
+
112
+ return {
113
+ FunctionDeclaration(node) {
114
+ // Only check top-level functions or functions inside exports
115
+ const isTopLevel = node.parent.type === 'Program' ||
116
+ (node.parent.type === 'ExportNamedDeclaration' && node.parent.parent.type === 'Program') ||
117
+ (node.parent.type === 'ExportDefaultDeclaration' && node.parent.parent.type === 'Program');
118
+
119
+ if (!isTopLevel) {
120
+ return;
121
+ }
122
+
123
+ if (node.id && node.id.name !== filename) {
124
+ // Special case: ignore if filename is 'index'
125
+ if (filename === 'index') {
126
+ return;
127
+ }
128
+
129
+ context.report({
130
+ node: node.id,
131
+ message: 'Function name "{{ name }}" does not match filename "{{ filename }}".',
132
+ data: {
133
+ name: node.id.name,
134
+ filename: filename
135
+ }
136
+ });
137
+ }
138
+ }
139
+ };
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Rule: Folder names must be camelCase
145
+ * @type {import('eslint').Rule.RuleModule}
146
+ */
147
+ const folderCamelCaseRule = {
148
+ meta: {
149
+ type: 'suggestion',
150
+ docs: {
151
+ description: 'Enforce that folder names are camelCase',
152
+ category: 'Best Practices',
153
+ recommended: false
154
+ },
155
+ schema: []
156
+ },
157
+ create(context) {
158
+ return {
159
+ Program() {
160
+ const fullPath = context.getFilename();
161
+
162
+ if (fullPath === '<input>' || fullPath === '<text>') {
163
+ return;
164
+ }
165
+
166
+ const dirPath = path.dirname(fullPath);
167
+ const relativePath = path.relative(process.cwd(), dirPath);
168
+
169
+ if (!relativePath || relativePath === '.') {
170
+ return;
171
+ }
172
+
173
+ const folders = relativePath.split(path.sep);
174
+ const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
175
+
176
+ for (const folder of folders) {
177
+ // Skip node_modules, dist, tests, and hidden folders
178
+ if (folder === 'node_modules' || folder === 'dist' || folder === 'tests' || folder.startsWith('.')) {
179
+ continue;
180
+ }
181
+
182
+ // Skip common top-level folders that might not be camelCase (optional, but good for compatibility)
183
+ if (['src', 'apps', 'packages', 'tools', 'docs', 'config'].includes(folder)) {
184
+ continue;
185
+ }
186
+
187
+ if (!camelCaseRegex.test(folder)) {
188
+ context.report({
189
+ loc: { line: 1, column: 0 },
190
+ message: 'Folder name "{{ folder }}" should be camelCase.',
191
+ data: { folder }
192
+ });
193
+ }
194
+ }
195
+ }
196
+ };
197
+ }
198
+ };
199
+
200
+ /**
201
+ * Rule: Helpers must have only static methods
202
+ * @type {import('eslint').Rule.RuleModule}
203
+ */
204
+ const helperStaticOnlyRule = {
205
+ meta: {
206
+ type: 'problem',
207
+ docs: {
208
+ description: 'Enforce that Helper classes only contain static methods',
209
+ category: 'Best Practices',
210
+ recommended: false
211
+ },
212
+ schema: []
213
+ },
214
+ create(context) {
215
+ return {
216
+ ClassDeclaration(node) {
217
+ if (!node.id || !node.id.name.endsWith('Helper')) {
218
+ return;
219
+ }
220
+
221
+ const nonStaticMembers = node.body.body.filter(member => {
222
+ // Skip constructors
223
+ if (member.type === 'MethodDefinition' && member.kind === 'constructor') {
224
+ return false;
225
+ }
226
+
227
+ // Check for non-static methods and properties
228
+ return (
229
+ (member.type === 'MethodDefinition' || member.type === 'PropertyDefinition') &&
230
+ !member.static
231
+ );
232
+ });
233
+
234
+ if (nonStaticMembers.length > 0) {
235
+ for (const member of nonStaticMembers) {
236
+ const memberName = member.key?.name || 'unknown';
237
+
238
+ context.report({
239
+ node: member,
240
+ message: 'Helper class "{{ className }}" should only have static members. Member "{{ memberName }}" is not static.',
241
+ data: {
242
+ className: node.id.name,
243
+ memberName
244
+ }
245
+ });
246
+ }
247
+ }
248
+ }
249
+ };
250
+ }
251
+ };
252
+
253
+ /**
254
+ * Rule: Non-helper classes should not have static methods (except factories)
255
+ * @type {import('eslint').Rule.RuleModule}
256
+ */
257
+ const noStaticInNonHelpersRule = {
258
+ meta: {
259
+ type: 'suggestion',
260
+ docs: {
261
+ description: 'Enforce that non-Helper/Factory classes do not have static methods',
262
+ category: 'Best Practices',
263
+ recommended: false
264
+ },
265
+ schema: []
266
+ },
267
+ create(context) {
268
+ return {
269
+ ClassDeclaration(node) {
270
+ if (!node.id) {
271
+ return;
272
+ }
273
+
274
+ const className = node.id.name;
275
+
276
+ // Allow static methods in Helpers, Factories, and Registries
277
+ if (className.endsWith('Helper') || className.endsWith('Factory') || className.endsWith('Registry')) {
278
+ return;
279
+ }
280
+
281
+ const staticMembers = node.body.body.filter(member => {
282
+ return (
283
+ (member.type === 'MethodDefinition' || member.type === 'PropertyDefinition') &&
284
+ member.static
285
+ );
286
+ });
287
+
288
+ if (staticMembers.length > 0) {
289
+ for (const member of staticMembers) {
290
+ const memberName = member.key?.name || 'unknown';
291
+
292
+ context.report({
293
+ node: member,
294
+ message: 'Class "{{ className }}" should not have static members. Use a Helper class for static methods. Static member: "{{ memberName }}".',
295
+ data: {
296
+ className,
297
+ memberName
298
+ }
299
+ });
300
+ }
301
+ }
302
+ }
303
+ };
304
+ }
305
+ };
306
+
307
+ /**
308
+ * Rule: Classes must be in appropriate folders based on their suffix
309
+ * @type {import('eslint').Rule.RuleModule}
310
+ */
311
+ const classLocationRule = {
312
+ meta: {
313
+ type: 'suggestion',
314
+ docs: {
315
+ description: 'Enforce that classes are located in appropriate folders based on their suffix',
316
+ category: 'Best Practices',
317
+ recommended: false
318
+ },
319
+ schema: [
320
+ {
321
+ type: 'object',
322
+ properties: {
323
+ mappings: {
324
+ type: 'object',
325
+ additionalProperties: {
326
+ type: 'string'
327
+ }
328
+ }
329
+ },
330
+ additionalProperties: false
331
+ }
332
+ ]
333
+ },
334
+ create(context) {
335
+ const options = context.options[0] || {};
336
+ const defaultMappings = {
337
+ Service: 'services',
338
+ Repository: 'repositories',
339
+ Helper: 'helpers',
340
+ Factory: 'factories',
341
+ Transformer: 'transformers',
342
+ Registry: 'registries',
343
+ Adapter: 'adapters'
344
+ };
345
+ const mappings = { ...defaultMappings, ...options.mappings };
346
+
347
+ return {
348
+ ClassDeclaration(node) {
349
+ if (!node.id) {
350
+ return;
351
+ }
352
+
353
+ const className = node.id.name;
354
+ const fullPath = context.getFilename();
355
+
356
+ if (fullPath === '<input>' || fullPath === '<text>') {
357
+ return;
358
+ }
359
+
360
+ const dirPath = path.dirname(fullPath);
361
+
362
+ for (const [suffix, expectedFolder] of Object.entries(mappings)) {
363
+ if (className.endsWith(suffix)) {
364
+ // Check if the file is in the expected folder or a subfolder
365
+ const pathParts = dirPath.split(path.sep);
366
+
367
+ if (!pathParts.includes(expectedFolder)) {
368
+ context.report({
369
+ node: node.id,
370
+ message: 'Class "{{ className }}" with suffix "{{ suffix }}" should be in a "{{ expectedFolder }}" folder.',
371
+ data: {
372
+ className,
373
+ suffix,
374
+ expectedFolder
375
+ }
376
+ });
377
+ }
378
+
379
+ break; // Only check first matching suffix
380
+ }
381
+ }
382
+ }
383
+ };
384
+ }
385
+ };
386
+
387
+ /**
388
+ * Rule: Interface files with TypeXXX must be in types folder
389
+ * @type {import('eslint').Rule.RuleModule}
390
+ */
391
+ const typeLocationRule = {
392
+ meta: {
393
+ type: 'suggestion',
394
+ docs: {
395
+ description: 'Enforce that Type interfaces are located in types folder',
396
+ category: 'Best Practices',
397
+ recommended: false
398
+ },
399
+ schema: []
400
+ },
401
+ create(context) {
402
+ return {
403
+ TSInterfaceDeclaration(node) {
404
+ if (!node.id) {
405
+ return;
406
+ }
407
+
408
+ const interfaceName = node.id.name;
409
+
410
+ // Check if it's a Type interface (starts with Type)
411
+ if (!interfaceName.startsWith('Type')) {
412
+ return;
413
+ }
414
+
415
+ const fullPath = context.getFilename();
416
+
417
+ if (fullPath === '<input>' || fullPath === '<text>') {
418
+ return;
419
+ }
420
+
421
+ const dirPath = path.dirname(fullPath);
422
+ const pathParts = dirPath.split(path.sep);
423
+
424
+ if (!pathParts.includes('types')) {
425
+ context.report({
426
+ node: node.id,
427
+ message: 'Type interface "{{ interfaceName }}" should be in a "types" folder.',
428
+ data: { interfaceName }
429
+ });
430
+ }
431
+ },
432
+ TSTypeAliasDeclaration(node) {
433
+ if (!node.id) {
434
+ return;
435
+ }
436
+
437
+ const typeName = node.id.name;
438
+
439
+ // Check if it's a Type alias (starts with Type)
440
+ if (!typeName.startsWith('Type')) {
441
+ return;
442
+ }
443
+
444
+ const fullPath = context.getFilename();
445
+
446
+ if (fullPath === '<input>' || fullPath === '<text>') {
447
+ return;
448
+ }
449
+
450
+ const dirPath = path.dirname(fullPath);
451
+ const pathParts = dirPath.split(path.sep);
452
+
453
+ if (!pathParts.includes('types')) {
454
+ context.report({
455
+ node: node.id,
456
+ message: 'Type alias "{{ typeName }}" should be in a "types" folder.',
457
+ data: { typeName }
458
+ });
459
+ }
460
+ }
461
+ };
462
+ }
463
+ };
464
+
465
+ /**
466
+ * Rule: Transformers must have single public method
467
+ * @type {import('eslint').Rule.RuleModule}
468
+ */
469
+ const transformerSinglePublicMethodRule = {
470
+ meta: {
471
+ type: 'suggestion',
472
+ docs: {
473
+ description: 'Enforce that transformers have only one public method',
474
+ category: 'Best Practices',
475
+ recommended: false
476
+ },
477
+ schema: []
478
+ },
479
+ create(context) {
480
+ return {
481
+ ClassDeclaration(node) {
482
+ if (!node.id || !node.id.name.endsWith('Transformer')) {
483
+ return;
484
+ }
485
+
486
+ const publicMethods = node.body.body.filter(member => {
487
+ return (
488
+ member.type === 'MethodDefinition' &&
489
+ member.kind === 'method' &&
490
+ (member.accessibility === 'public' || !member.accessibility) &&
491
+ !member.static
492
+ );
493
+ });
494
+
495
+ if (publicMethods.length > 1) {
496
+ context.report({
497
+ node: node.id,
498
+ message: 'Transformer {{ name }} has {{ count }} public methods. Transformers should have only one public method.',
499
+ data: {
500
+ name: node.id.name,
501
+ count: publicMethods.length
502
+ }
503
+ });
504
+ }
505
+ }
506
+ };
507
+ }
508
+ };
509
+
510
+ /**
511
+ * Rule: Only one class per file
512
+ * @type {import('eslint').Rule.RuleModule}
513
+ */
514
+ const oneClassPerFileRule = {
515
+ meta: {
516
+ type: 'suggestion',
517
+ docs: {
518
+ description: 'Enforce that each file contains only one class',
519
+ category: 'Best Practices',
520
+ recommended: false
521
+ },
522
+ schema: []
523
+ },
524
+ create(context) {
525
+ const classes = [];
526
+
527
+ return {
528
+ ClassDeclaration(node) {
529
+ if (node.id) {
530
+ classes.push(node);
531
+ }
532
+ },
533
+ 'Program:exit'() {
534
+ if (classes.length > 1) {
535
+ // Report on all classes except the first one
536
+ for (let idx = 1; idx < classes.length; idx++) {
537
+ context.report({
538
+ node: classes[idx].id,
539
+ message: 'File contains multiple classes. Each class should be in its own file. Found {{ count }} classes.',
540
+ data: {
541
+ count: classes.length
542
+ }
543
+ });
544
+ }
545
+ }
546
+ }
547
+ };
548
+ }
549
+ };
550
+
551
+ /**
552
+ * Rule: Repository CQRS method naming
553
+ * @type {import('eslint').Rule.RuleModule}
554
+ */
555
+ const repositoryCqrsRule = {
556
+ meta: {
557
+ type: 'suggestion',
558
+ docs: {
559
+ description: 'Enforce CQRS naming for repository classes (CommandRepository vs QueryRepository)',
560
+ category: 'Best Practices',
561
+ recommended: false
562
+ },
563
+ schema: []
564
+ },
565
+ create(context) {
566
+ const commandMethods = ['create', 'update', 'delete', 'save', 'insert', 'remove', 'add', 'set'];
567
+ const queryMethods = ['get', 'find', 'fetch', 'list', 'search', 'query', 'read', 'load', 'retrieve'];
568
+
569
+ return {
570
+ ClassDeclaration(node) {
571
+ if (!node.id) {
572
+ return;
573
+ }
574
+
575
+ const className = node.id.name;
576
+
577
+ // Only check Repository classes
578
+ if (!className.endsWith('Repository')) {
579
+ return;
580
+ }
581
+
582
+ const isCommandRepository = className.includes('Command');
583
+ const isQueryRepository = className.includes('Query');
584
+
585
+ // If not explicitly typed, skip this check (backward compatibility)
586
+ if (!isCommandRepository && !isQueryRepository) {
587
+ return;
588
+ }
589
+
590
+ const publicMethods = node.body.body.filter(member => {
591
+ return (
592
+ member.type === 'MethodDefinition' &&
593
+ member.kind === 'method' &&
594
+ (member.accessibility === 'public' || !member.accessibility) &&
595
+ !member.static &&
596
+ member.key?.name
597
+ );
598
+ });
599
+
600
+ for (const method of publicMethods) {
601
+ const methodName = method.key.name.toLowerCase();
602
+
603
+ if (isCommandRepository) {
604
+ // Command repositories should not have query methods
605
+ const hasQueryMethod = queryMethods.some(queryMethod => methodName.startsWith(queryMethod));
606
+
607
+ if (hasQueryMethod) {
608
+ context.report({
609
+ node: method.key,
610
+ message: 'CommandRepository "{{ className }}" should not have query method "{{ methodName }}". Use a QueryRepository for read operations.',
611
+ data: {
612
+ className,
613
+ methodName: method.key.name
614
+ }
615
+ });
616
+ }
617
+ }
618
+ else if (isQueryRepository) {
619
+ // Query repositories should not have command methods
620
+ const hasCommandMethod = commandMethods.some(cmdMethod => methodName.startsWith(cmdMethod));
621
+
622
+ if (hasCommandMethod) {
623
+ context.report({
624
+ node: method.key,
625
+ message: 'QueryRepository "{{ className }}" should not have command method "{{ methodName }}". Use a CommandRepository for write operations.',
626
+ data: {
627
+ className,
628
+ methodName: method.key.name
629
+ }
630
+ });
631
+ }
632
+ }
633
+ }
634
+ }
635
+ };
636
+ }
637
+ };
638
+
639
+ /**
640
+ * Rule: No Zod schemas in files with classes
641
+ * @type {import('eslint').Rule.RuleModule}
642
+ */
643
+ const noSchemasInClassFilesRule = {
644
+ meta: {
645
+ type: 'suggestion',
646
+ docs: {
647
+ description: 'Enforce separation of schemas from class files',
648
+ category: 'Best Practices',
649
+ recommended: false
650
+ },
651
+ schema: []
652
+ },
653
+ create(context) {
654
+ let hasClass = false;
655
+ const schemas = [];
656
+
657
+ return {
658
+ ClassDeclaration() {
659
+ hasClass = true;
660
+ },
661
+ VariableDeclarator(node) {
662
+ if (node.id.type === 'Identifier') {
663
+ const name = node.id.name;
664
+ // Check for Schema suffix or z.object/z.string initialization
665
+ const isSchemaName = name.endsWith('Schema');
666
+ let isZodInit = false;
667
+
668
+ if (node.init && node.init.type === 'CallExpression' &&
669
+ node.init.callee.type === 'MemberExpression' &&
670
+ node.init.callee.object.type === 'Identifier' &&
671
+ node.init.callee.object.name === 'z') {
672
+ isZodInit = true;
673
+ }
674
+
675
+ if (isSchemaName || isZodInit) {
676
+ // Check if top-level
677
+ if (node.parent.parent.type === 'Program' ||
678
+ (node.parent.parent.type === 'ExportNamedDeclaration' && node.parent.parent.parent.type === 'Program')) {
679
+ schemas.push(node);
680
+ }
681
+ }
682
+ }
683
+ },
684
+ 'Program:exit'() {
685
+ if (hasClass && schemas.length > 0) {
686
+ schemas.forEach(node => {
687
+ context.report({
688
+ node: node.id,
689
+ message: 'Schema definition "{{ name }}" should not be in a class file. Move it to a separate schemas file or folder.',
690
+ data: {
691
+ name: node.id.name
692
+ }
693
+ });
694
+ });
695
+ }
696
+ }
697
+ };
698
+ }
699
+ };
700
+
701
+ /**
702
+ * Rule: No types/interfaces in files with classes
703
+ * @type {import('eslint').Rule.RuleModule}
704
+ */
705
+ const noTypesInClassFilesRule = {
706
+ meta: {
707
+ type: 'suggestion',
708
+ docs: {
709
+ description: 'Enforce separation of types and interfaces from class files',
710
+ category: 'Best Practices',
711
+ recommended: false
712
+ },
713
+ schema: []
714
+ },
715
+ create(context) {
716
+ let hasClass = false;
717
+ const types = [];
718
+
719
+ return {
720
+ ClassDeclaration() {
721
+ hasClass = true;
722
+ },
723
+ TSTypeAliasDeclaration(node) {
724
+ if (node.parent.type === 'Program' ||
725
+ (node.parent.type === 'ExportNamedDeclaration' && node.parent.parent.type === 'Program')) {
726
+ types.push(node);
727
+ }
728
+ },
729
+ TSInterfaceDeclaration(node) {
730
+ if (node.parent.type === 'Program' ||
731
+ (node.parent.type === 'ExportNamedDeclaration' && node.parent.parent.type === 'Program')) {
732
+ types.push(node);
733
+ }
734
+ },
735
+ 'Program:exit'() {
736
+ if (hasClass && types.length > 0) {
737
+ types.forEach(node => {
738
+ context.report({
739
+ node: node.id,
740
+ message: 'Type/Interface definition "{{ name }}" should not be in a class file. Move it to a separate types file or folder.',
741
+ data: {
742
+ name: node.id.name
743
+ }
744
+ });
745
+ });
746
+ }
747
+ }
748
+ };
749
+ }
750
+ };
751
+
752
+ /**
753
+ * Rule: No top-level constants in files with classes
754
+ * @type {import('eslint').Rule.RuleModule}
755
+ */
756
+ const noConstantsInClassFilesRule = {
757
+ meta: {
758
+ type: 'suggestion',
759
+ docs: {
760
+ description: 'Enforce separation of constants from class files',
761
+ category: 'Best Practices',
762
+ recommended: false
763
+ },
764
+ schema: []
765
+ },
766
+ create(context) {
767
+ let hasClass = false;
768
+ const constants = [];
769
+
770
+ return {
771
+ ClassDeclaration() {
772
+ hasClass = true;
773
+ },
774
+ VariableDeclaration(node) {
775
+ // Check if top-level const
776
+ if (node.kind === 'const' &&
777
+ (node.parent.type === 'Program' ||
778
+ (node.parent.type === 'ExportNamedDeclaration' && node.parent.parent.type === 'Program'))) {
779
+
780
+ node.declarations.forEach(decl => {
781
+ // Skip requires
782
+ if (decl.init && decl.init.type === 'CallExpression' && decl.init.callee.name === 'require') {
783
+ return;
784
+ }
785
+ // Skip Zod schemas (covered by other rule)
786
+ if (decl.id.name && decl.id.name.endsWith('Schema')) {
787
+ return;
788
+ }
789
+ if (decl.init && decl.init.type === 'CallExpression' &&
790
+ decl.init.callee.type === 'MemberExpression' &&
791
+ decl.init.callee.object.name === 'z') {
792
+ return;
793
+ }
794
+
795
+ constants.push(decl);
796
+ });
797
+ }
798
+ },
799
+ 'Program:exit'() {
800
+ if (hasClass && constants.length > 0) {
801
+ constants.forEach(node => {
802
+ context.report({
803
+ node: node.id,
804
+ message: 'Constant "{{ name }}" should not be in a class file. Move it to a separate constants file or use a static readonly class property.',
805
+ data: {
806
+ name: node.id.name
807
+ }
808
+ });
809
+ });
810
+ }
811
+ }
812
+ };
813
+ }
814
+ };
815
+
816
+ /**
817
+ * Rule: Interfaces must start with Type or end with Interface
818
+ * @type {import('eslint').Rule.RuleModule}
819
+ */
820
+ const interfaceNamingRule = {
821
+ meta: {
822
+ type: 'suggestion',
823
+ docs: {
824
+ description: 'Enforce that interface names start with Type or end with Interface',
825
+ category: 'Naming Conventions',
826
+ recommended: false
827
+ },
828
+ schema: []
829
+ },
830
+ create(context) {
831
+ return {
832
+ TSInterfaceDeclaration(node) {
833
+ if (!node.id) return;
834
+ const name = node.id.name;
835
+ if (!name.startsWith('Type') && !name.endsWith('Interface')) {
836
+ context.report({
837
+ node: node.id,
838
+ message: 'Interface "{{ name }}" should start with "Type" (for data) or end with "Interface" (for contracts).',
839
+ data: { name }
840
+ });
841
+ }
842
+ }
843
+ };
844
+ }
845
+ };
846
+
847
+ /**
848
+ * Rule: Functions must have explicit return types
849
+ * @type {import('eslint').Rule.RuleModule}
850
+ */
851
+ const explicitReturnTypeRule = {
852
+ meta: {
853
+ type: 'suggestion',
854
+ docs: {
855
+ description: 'Enforce explicit return types for functions',
856
+ category: 'Type Safety',
857
+ recommended: false
858
+ },
859
+ schema: []
860
+ },
861
+ create(context) {
862
+ function checkFunction(node) {
863
+ if (!node.returnType) {
864
+ const name = node.id ? node.id.name : (node.key ? node.key.name : 'anonymous');
865
+ context.report({
866
+ node: node.id || node.key || node,
867
+ message: 'Function/Method "{{ name }}" is missing an explicit return type.',
868
+ data: { name }
869
+ });
870
+ }
871
+ }
872
+
873
+ return {
874
+ FunctionDeclaration: checkFunction,
875
+ MethodDefinition: (node) => {
876
+ if (node.kind === 'constructor' || node.kind === 'set') return;
877
+ checkFunction(node.value);
878
+ },
879
+ ArrowFunctionExpression: checkFunction,
880
+ FunctionExpression: (node) => {
881
+ if (node.parent.type === 'MethodDefinition') return;
882
+ checkFunction(node);
883
+ }
884
+ };
885
+ }
886
+ };
887
+
888
+ /**
889
+ * Rule: No direct instantiation of classes inside other classes (dependency injection)
890
+ * @type {import('eslint').Rule.RuleModule}
891
+ */
892
+ const noDirectInstantiationRule = {
893
+ meta: {
894
+ type: 'suggestion',
895
+ docs: {
896
+ description: 'Enforce dependency injection by banning direct instantiation',
897
+ category: 'Best Practices',
898
+ recommended: false
899
+ },
900
+ schema: []
901
+ },
902
+ create(context) {
903
+ const allowedClasses = new Set([
904
+ 'Date', 'Error', 'Promise', 'Map', 'Set', 'WeakMap', 'WeakSet',
905
+ 'RegExp', 'URL', 'URLSearchParams', 'Buffer', 'Array', 'Object', 'String', 'Number', 'Boolean'
906
+ ]);
907
+
908
+ let inClass = false;
909
+ let currentClassName = '';
910
+
911
+ return {
912
+ ClassDeclaration(node) {
913
+ inClass = true;
914
+ if (node.id) {
915
+ currentClassName = node.id.name;
916
+ }
917
+ },
918
+ 'ClassDeclaration:exit'() {
919
+ inClass = false;
920
+ currentClassName = '';
921
+ },
922
+ NewExpression(node) {
923
+ if (!inClass) return;
924
+
925
+ // Allow factories to instantiate objects
926
+ if (currentClassName.endsWith('Factory')) {
927
+ return;
928
+ }
929
+
930
+ if (node.callee.type === 'Identifier') {
931
+ const className = node.callee.name;
932
+ if (!allowedClasses.has(className)) {
933
+ context.report({
934
+ node: node,
935
+ message: 'Avoid direct instantiation of "{{ name }}". Use dependency injection instead.',
936
+ data: { name: className }
937
+ });
938
+ }
939
+ }
940
+ }
941
+ };
942
+ }
943
+ };
944
+
945
+ /**
946
+ * Rule: Prefer Enums over union types of string literals
947
+ * @type {import('eslint').Rule.RuleModule}
948
+ */
949
+ const preferEnumsRule = {
950
+ meta: {
951
+ type: 'suggestion',
952
+ docs: {
953
+ description: 'Prefer Enums over union types of string literals',
954
+ category: 'TypeScript',
955
+ recommended: false
956
+ },
957
+ schema: []
958
+ },
959
+ create(context) {
960
+ return {
961
+ TSTypeAliasDeclaration(node) {
962
+ if (node.typeAnnotation.type === 'TSUnionType') {
963
+ const isAllLiterals = node.typeAnnotation.types.every(
964
+ t => t.type === 'TSLiteralType' && (typeof t.literal.value === 'string' || typeof t.literal.value === 'number')
965
+ );
966
+
967
+ if (isAllLiterals && node.typeAnnotation.types.length > 1) {
968
+ context.report({
969
+ node: node.id,
970
+ message: 'Avoid union types for "{{ name }}". Use an Enum instead.',
971
+ data: { name: node.id.name }
972
+ });
973
+ }
974
+ }
975
+ }
976
+ };
977
+ }
978
+ };
979
+
980
+ /**
981
+ * Rule: Schemas must end with Schema or Table
982
+ * @type {import('eslint').Rule.RuleModule}
983
+ */
984
+ const schemaNamingRule = {
985
+ meta: {
986
+ type: 'suggestion',
987
+ docs: {
988
+ description: 'Enforce that Zod schemas and Drizzle tables end with Schema or Table',
989
+ category: 'Naming Conventions',
990
+ recommended: false
991
+ },
992
+ schema: []
993
+ },
994
+ create(context) {
995
+ return {
996
+ VariableDeclarator(node) {
997
+ if (node.init && node.init.type === 'CallExpression') {
998
+ let isZodOrDrizzle = false;
999
+
1000
+ // Check for z.something()
1001
+ if (node.init.callee.type === 'MemberExpression' &&
1002
+ node.init.callee.object.type === 'Identifier' &&
1003
+ node.init.callee.object.name === 'z') {
1004
+ isZodOrDrizzle = true;
1005
+ }
1006
+
1007
+ // Check for pgTable()
1008
+ if (node.init.callee.type === 'Identifier' && node.init.callee.name === 'pgTable') {
1009
+ isZodOrDrizzle = true;
1010
+ }
1011
+
1012
+ if (isZodOrDrizzle && node.id.type === 'Identifier') {
1013
+ const name = node.id.name;
1014
+ if (!name.endsWith('Schema') && !name.endsWith('Table')) {
1015
+ context.report({
1016
+ node: node.id,
1017
+ message: 'Schema/Table definition "{{ name }}" should end with "Schema" or "Table".',
1018
+ data: { name }
1019
+ });
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ };
1025
+ }
1026
+ };
1027
+
1028
+ /**
1029
+ * Rule: Repository methods accepting 'id' must end with 'ById'
1030
+ * @type {import('eslint').Rule.RuleModule}
1031
+ */
1032
+ const repositoryByIdRule = {
1033
+ meta: {
1034
+ type: 'suggestion',
1035
+ docs: {
1036
+ description: 'Enforce "ById" suffix for repository methods that take an "id" parameter',
1037
+ category: 'Naming Conventions',
1038
+ recommended: false
1039
+ },
1040
+ schema: []
1041
+ },
1042
+ create(context) {
1043
+ return {
1044
+ MethodDefinition(node) {
1045
+ // Only check methods in Repository classes
1046
+ const classNode = node.parent.parent;
1047
+ if (!classNode || !classNode.id || !classNode.id.name.endsWith('Repository')) {
1048
+ return;
1049
+ }
1050
+
1051
+ // Skip constructors and static methods
1052
+ if (node.kind === 'constructor' || node.static) {
1053
+ return;
1054
+ }
1055
+
1056
+ const methodParam = node.value.params[0];
1057
+ if (!methodParam) {
1058
+ return;
1059
+ }
1060
+
1061
+ // Check if first parameter is named 'id'
1062
+ let paramName = '';
1063
+ if (methodParam.type === 'Identifier') {
1064
+ paramName = methodParam.name;
1065
+ }
1066
+
1067
+ if (paramName === 'id') {
1068
+ const methodName = node.key.name;
1069
+ if (!methodName.endsWith('ById')) {
1070
+ context.report({
1071
+ node: node.key,
1072
+ message: 'Repository method "{{ methodName }}" accepts "id" parameter but does not end with "ById". Rename to "{{ methodName }}ById".',
1073
+ data: { methodName }
1074
+ });
1075
+ }
1076
+ }
1077
+ }
1078
+ };
1079
+ }
1080
+ };
1081
+
1082
+ /**
1083
+ * Rule: Avoid using 'utils' folder, prefer 'helpers'
1084
+ * @type {import('eslint').Rule.RuleModule}
1085
+ */
1086
+ const noUtilsFolderRule = {
1087
+ meta: {
1088
+ type: 'suggestion',
1089
+ docs: {
1090
+ description: 'Enforce that "utils" folders are not used, preferring "helpers" instead',
1091
+ category: 'Best Practices',
1092
+ recommended: false
1093
+ },
1094
+ schema: []
1095
+ },
1096
+ create(context) {
1097
+ return {
1098
+ Program(node) {
1099
+ const fullPath = context.getFilename();
1100
+
1101
+ if (fullPath === '<input>' || fullPath === '<text>') {
1102
+ return;
1103
+ }
1104
+
1105
+ const dirPath = path.dirname(fullPath);
1106
+ const relativePath = path.relative(process.cwd(), dirPath);
1107
+
1108
+ if (!relativePath || relativePath === '.') {
1109
+ return;
1110
+ }
1111
+
1112
+ const folders = relativePath.split(path.sep);
1113
+
1114
+ // Check if any folder is named 'utils' or 'util'
1115
+ for (const folder of folders) {
1116
+ if (folder.toLowerCase() === 'utils' || folder.toLowerCase() === 'util') {
1117
+ context.report({
1118
+ node: node,
1119
+ loc: { line: 1, column: 0 },
1120
+ message: 'Avoid using "{{ folder }}" folder. Use "helpers" for shared logic, or specific domain names (e.g. "services", "factories").',
1121
+ data: { folder }
1122
+ });
1123
+ // Report only once per file
1124
+ break;
1125
+ }
1126
+ }
1127
+ }
1128
+ };
1129
+ }
1130
+ };
1131
+
1132
+ export default {
1133
+ rules: {
1134
+ 'service-single-public-method': serviceSinglePublicMethodRule,
1135
+ 'factory-single-public-method': factorySinglePublicMethodRule,
1136
+ 'function-name-match-filename': functionNameMatchFilenameRule,
1137
+ 'folder-camel-case': folderCamelCaseRule,
1138
+ 'helper-static-only': helperStaticOnlyRule,
1139
+ 'no-static-in-non-helpers': noStaticInNonHelpersRule,
1140
+ 'class-location': classLocationRule,
1141
+ 'type-location': typeLocationRule,
1142
+ 'transformer-single-public-method': transformerSinglePublicMethodRule,
1143
+ 'one-class-per-file': oneClassPerFileRule,
1144
+ 'repository-cqrs': repositoryCqrsRule,
1145
+ 'no-schemas-in-class-files': noSchemasInClassFilesRule,
1146
+ 'no-types-in-class-files': noTypesInClassFilesRule,
1147
+ 'no-constants-in-class-files': noConstantsInClassFilesRule,
1148
+ 'interface-naming': interfaceNamingRule,
1149
+ 'explicit-return-type': explicitReturnTypeRule,
1150
+ 'no-direct-instantiation': noDirectInstantiationRule,
1151
+ 'prefer-enums': preferEnumsRule,
1152
+ 'schema-naming': schemaNamingRule,
1153
+ 'repository-by-id': repositoryByIdRule,
1154
+ 'no-utils-folder': noUtilsFolderRule
1155
+ }
1156
+ };