@aneuhold/eslint-config 2.0.5 → 3.0.1

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.
Files changed (28) hide show
  1. package/README.md +15 -54
  2. package/package.json +25 -19
  3. package/src/{react-next-config.js → configs/react-next-config.ts} +6 -5
  4. package/src/{svelte-config.js → configs/svelte-config.ts} +2 -2
  5. package/src/{ts-lib-config.js → configs/ts-lib-config.ts} +4 -9
  6. package/src/rules/index.ts +45 -0
  7. package/src/rules/no-private-modifier/buildClassFixes.ts +39 -0
  8. package/src/rules/no-private-modifier/classFrame.ts +143 -0
  9. package/src/rules/no-private-modifier/classReports.ts +55 -0
  10. package/src/rules/no-private-modifier/no-private-modifier.md +75 -0
  11. package/src/rules/no-private-modifier/no-private-modifier.test.ts +105 -0
  12. package/src/rules/no-private-modifier/no-private-modifier.ts +130 -0
  13. package/src/rules/no-private-modifier/types.ts +42 -0
  14. package/src/rules/service-file-structure/isServiceFile.ts +51 -0
  15. package/src/rules/service-file-structure/messages.ts +25 -0
  16. package/src/rules/service-file-structure/service-file-structure.md +97 -0
  17. package/src/rules/service-file-structure/service-file-structure.test.ts +159 -0
  18. package/src/rules/service-file-structure/service-file-structure.ts +61 -0
  19. package/src/rules/service-file-structure/serviceModel.ts +134 -0
  20. package/src/rules/service-file-structure/validations/validateDefaultExportIsConstBound.ts +40 -0
  21. package/src/rules/service-file-structure/validations/validateFileNamingConvention.ts +18 -0
  22. package/src/rules/service-file-structure/validations/validateNoTopLevelFunctions.ts +23 -0
  23. package/src/rules/service-file-structure/validations/validateNoTopLevelVariables.ts +33 -0
  24. package/src/rules/service-file-structure/validations/validateSingletonName.ts +26 -0
  25. package/src/utils/createRule.ts +12 -0
  26. package/src/utils/eslintTestSetup.ts +19 -0
  27. /package/src/{angular-config.js → configs/angular-config.ts} +0 -0
  28. /package/src/{react-config.js → configs/react-config.ts} +0 -0
@@ -0,0 +1,105 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { setupEslintRuleTester } from '../../utils/eslintTestSetup';
3
+ import { noPrivateModifier } from './no-private-modifier';
4
+
5
+ setupEslintRuleTester();
6
+
7
+ const ruleTester = new RuleTester({
8
+ languageOptions: {
9
+ parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
10
+ },
11
+ });
12
+
13
+ ruleTester.run('no-private-modifier', noPrivateModifier, {
14
+ valid: [
15
+ // `#private` field and method.
16
+ { code: `class Foo { #count = 0; }` },
17
+ { code: `class Foo { #helper(): void {} }` },
18
+ // Static `#private` field and method.
19
+ { code: `class Foo { static #count = 0; }` },
20
+ { code: `class Foo { static #helper(): void {} }` },
21
+ // `#private` accessor and getter.
22
+ { code: `class Foo { accessor #x = 1; }` },
23
+ { code: `class Foo { get #x(): number { return 1; } }` },
24
+ { code: `class Foo { constructor(foo: string) {} }` },
25
+ // Private constructors are allowed (no `#` constructor exists).
26
+ { code: `class Foo { private constructor() {} }` },
27
+ // Constructor parameter properties are allowed (no `#` shorthand exists).
28
+ { code: `class Foo { constructor(private foo: string) {} }` },
29
+ { code: `class Foo { constructor(private readonly foo: string) {} }` },
30
+ // `public`/`protected`/no-modifier members are out of scope.
31
+ { code: `class Foo { public count = 0; }` },
32
+ { code: `class Foo { protected helper(): void {} }` },
33
+ { code: `class Foo { count = 0; }` },
34
+ { code: `class Foo { static value = 1; }` },
35
+ ],
36
+ invalid: [
37
+ // Private instance field, rewritten with its `this` reference.
38
+ {
39
+ code: `class Foo { private count = 0; read() { return this.count; } }`,
40
+ errors: [{ messageId: 'privateField' }],
41
+ output: `class Foo { #count = 0; read() { return this.#count; } }`,
42
+ },
43
+ // Field with no references is still converted.
44
+ {
45
+ code: `class Foo { private count = 0; }`,
46
+ errors: [{ messageId: 'privateField' }],
47
+ output: `class Foo { #count = 0; }`,
48
+ },
49
+ // Private method, rewritten along with its call site.
50
+ {
51
+ code: `class Foo { private helper() {} run() { this.helper(); } }`,
52
+ errors: [{ messageId: 'privateMethod' }],
53
+ output: `class Foo { #helper() {} run() { this.#helper(); } }`,
54
+ },
55
+ // Static field referenced via both `Foo.x` and `this.x`.
56
+ {
57
+ code: `class Foo { private static total = 0; static bump() { Foo.total++; this.total++; } }`,
58
+ errors: [{ messageId: 'privateField' }],
59
+ output: `class Foo { static #total = 0; static bump() { Foo.#total++; this.#total++; } }`,
60
+ },
61
+ // Accessor property.
62
+ {
63
+ code: `class Foo { private accessor label = ''; show() { return this.label; } }`,
64
+ errors: [{ messageId: 'privateField' }],
65
+ output: `class Foo { accessor #label = ''; show() { return this.#label; } }`,
66
+ },
67
+ // Getter/setter pair sharing a name: both declarations convert, the shared
68
+ // reference is rewritten exactly once.
69
+ {
70
+ code: `class Foo { private get x() { return 1; } private set x(v: number) {} use() { return this.x; } }`,
71
+ errors: [{ messageId: 'privateMethod' }, { messageId: 'privateMethod' }],
72
+ output: `class Foo { get #x() { return 1; } set #x(v: number) {} use() { return this.#x; } }`,
73
+ },
74
+ // Two distinct members in one class.
75
+ {
76
+ code: `class Foo { private count = 0; private helper() { return this.count; } }`,
77
+ errors: [{ messageId: 'privateField' }, { messageId: 'privateMethod' }],
78
+ output: `class Foo { #count = 0; #helper() { return this.#count; } }`,
79
+ },
80
+ // Possible cross-instance access (`other.id`) is ambiguous, so no fix.
81
+ {
82
+ code: `class Foo { private id = 1; eq(other: Foo): boolean { return this.id === other.id; } }`,
83
+ errors: [{ messageId: 'privateField' }],
84
+ output: null,
85
+ },
86
+ // Computed access can't be expressed as `#`, so no fix.
87
+ {
88
+ code: `class Foo { private val = 1; read() { return this['val']; } }`,
89
+ errors: [{ messageId: 'privateField' }],
90
+ output: null,
91
+ },
92
+ // Destructuring a private member off `this` has no `#` form, so no fix.
93
+ {
94
+ code: `class Foo { private val = 1; read() { const { val } = this; return val; } }`,
95
+ errors: [{ messageId: 'privateField' }],
96
+ output: null,
97
+ },
98
+ // A colliding `#count` already exists, so the rename is unsafe — report only.
99
+ {
100
+ code: `class Foo { #count = 1; private count = 2; }`,
101
+ errors: [{ messageId: 'privateField' }],
102
+ output: null,
103
+ },
104
+ ],
105
+ });
@@ -0,0 +1,130 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2
+ import { createRule } from '../../utils/createRule';
3
+ import { buildClassFixes } from './buildClassFixes';
4
+ import { classifyMemberAccess, createClassFrame, recordDestructuring } from './classFrame';
5
+ import { collectClassReports } from './classReports';
6
+ import { type ClassFrame, type ClassNode } from './types';
7
+
8
+ /**
9
+ * Enforces that class members marked private use the ECMAScript `#private`
10
+ * syntax rather than the TypeScript `private` accessibility modifier, and
11
+ * autofixes the conversion — declaration plus `this.x` / `ClassName.x`
12
+ * references — whenever it can do so safely. Covers instance and static fields,
13
+ * and methods (including accessors and getters/setters). Private constructors
14
+ * and constructor parameter properties (`constructor(private foo)`) are
15
+ * intentionally allowed, since the `#` form has no equivalent.
16
+ *
17
+ * References are gathered as ESLint makes its single pass over the file: a stack
18
+ * tracks the class currently being traversed, and a per-class counter tracks how
19
+ * far `this` has been rebound away from the instance by nested non-arrow
20
+ * functions. See `classFrame.ts` for how each access is classified.
21
+ */
22
+ export const noPrivateModifier = createRule({
23
+ name: 'no-private-modifier',
24
+ meta: {
25
+ type: 'suggestion',
26
+ fixable: 'code',
27
+ docs: {
28
+ description:
29
+ 'Class members must use the ECMAScript `#private` syntax instead of the TypeScript `private` accessibility modifier.',
30
+ },
31
+ messages: {
32
+ privateField:
33
+ 'Use a `#private` field instead of the TypeScript `private` modifier (e.g. `#count` rather than `private count`).',
34
+ privateMethod:
35
+ 'Use a `#private` method instead of the TypeScript `private` modifier (e.g. `#helper()` rather than `private helper()`).',
36
+ },
37
+ schema: [],
38
+ },
39
+ defaultOptions: [],
40
+ create(context) {
41
+ const { sourceCode } = context;
42
+ const classStack: ClassFrame[] = [];
43
+
44
+ /** Whether `this`, at the current point in traversal, is the class instance. */
45
+ const thisIsInstance = (): boolean => {
46
+ const frame = classStack.at(-1);
47
+ return frame !== undefined && frame.rebindDepth === 0;
48
+ };
49
+
50
+ /**
51
+ * Adjusts the current class's rebind depth when entering or leaving a
52
+ * function. A function that isn't a method of the class rebinds `this`.
53
+ *
54
+ * @param node The function being entered or left
55
+ * @param delta `+1` on enter, `-1` on exit
56
+ */
57
+ const adjustRebindDepth = (
58
+ node: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression,
59
+ delta: number
60
+ ): void => {
61
+ const frame = classStack.at(-1);
62
+ if (frame && node.parent.type !== AST_NODE_TYPES.MethodDefinition) {
63
+ frame.rebindDepth += delta;
64
+ }
65
+ };
66
+
67
+ /**
68
+ * Reports every `private` member of the class being left, attaching the
69
+ * single class-wide autofix to the first member that can be converted.
70
+ */
71
+ const reportClass = (): void => {
72
+ const frame = classStack.pop();
73
+ if (!frame) {
74
+ return;
75
+ }
76
+
77
+ const { reports, targets } = collectClassReports(frame);
78
+ const fixableMembers = new Set(targets.map((target) => target.member));
79
+ let fixAttached = false;
80
+
81
+ for (const { member, messageId } of reports) {
82
+ if (!fixAttached && fixableMembers.has(member)) {
83
+ fixAttached = true;
84
+ context.report({
85
+ node: member,
86
+ messageId,
87
+ fix: (fixer) => buildClassFixes(fixer, sourceCode, targets),
88
+ });
89
+ } else {
90
+ context.report({ node: member, messageId });
91
+ }
92
+ }
93
+ };
94
+
95
+ return {
96
+ ClassDeclaration: (node: ClassNode) => classStack.push(createClassFrame(node)),
97
+ ClassExpression: (node: ClassNode) => classStack.push(createClassFrame(node)),
98
+ 'ClassDeclaration:exit': () => {
99
+ reportClass();
100
+ },
101
+ 'ClassExpression:exit': () => {
102
+ reportClass();
103
+ },
104
+ FunctionDeclaration: (node) => {
105
+ adjustRebindDepth(node, 1);
106
+ },
107
+ FunctionExpression: (node) => {
108
+ adjustRebindDepth(node, 1);
109
+ },
110
+ 'FunctionDeclaration:exit': (node) => {
111
+ adjustRebindDepth(node, -1);
112
+ },
113
+ 'FunctionExpression:exit': (node) => {
114
+ adjustRebindDepth(node, -1);
115
+ },
116
+ MemberExpression(node) {
117
+ const frame = classStack.at(-1);
118
+ if (frame && frame.candidateNames.size > 0) {
119
+ classifyMemberAccess(frame, node, thisIsInstance());
120
+ }
121
+ },
122
+ ObjectPattern(node) {
123
+ const frame = classStack.at(-1);
124
+ if (frame && frame.candidateNames.size > 0) {
125
+ recordDestructuring(frame, node);
126
+ }
127
+ },
128
+ };
129
+ },
130
+ });
@@ -0,0 +1,42 @@
1
+ import { type TSESTree } from '@typescript-eslint/utils';
2
+
3
+ /** A class declaration or expression. */
4
+ export type ClassNode = TSESTree.ClassDeclaration | TSESTree.ClassExpression;
5
+
6
+ /** A class member that can carry the `private` modifier and be converted to `#`. */
7
+ export type FixableMember =
8
+ | TSESTree.PropertyDefinition
9
+ | TSESTree.AccessorProperty
10
+ | TSESTree.MethodDefinition;
11
+
12
+ /** A message this rule can report. */
13
+ export type MessageId = 'privateField' | 'privateMethod';
14
+
15
+ /** A member to convert, paired with the references that convert alongside it. */
16
+ export type FixTarget = { member: FixableMember; refs: TSESTree.MemberExpression[] };
17
+
18
+ /**
19
+ * Everything learned about one class while ESLint traverses it: which private
20
+ * members are candidates for conversion, which `#names` already exist, and the
21
+ * references discovered so far (safely rewritable vs. blocked).
22
+ */
23
+ export type ClassFrame = {
24
+ classNode: ClassNode;
25
+ className: string | null;
26
+ /** Private member names eligible for conversion. */
27
+ candidateNames: Set<string>;
28
+ /** `#name` identifiers already present; renaming onto one would collide. */
29
+ existingPrivateNames: Set<string>;
30
+ /** `this.name` accesses that an autofix would rewrite. */
31
+ thisRefs: Map<string, TSESTree.MemberExpression[]>;
32
+ /** `ClassName.name` accesses (statics) that an autofix would rewrite. */
33
+ staticRefs: Map<string, TSESTree.MemberExpression[]>;
34
+ /** Names with a reference the fix can't safely rewrite, so won't be touched. */
35
+ blocked: Set<string>;
36
+ /**
37
+ * How many non-arrow functions are currently open inside this class without
38
+ * being one of its methods — i.e. how deeply `this` has been rebound away
39
+ * from the instance. Zero means `this` refers to the instance.
40
+ */
41
+ rebindDepth: number;
42
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Matches a service source file: a basename ending in `service.ts` or
3
+ * `service.svelte.ts`, where only the leading `S` is case-insensitive (so
4
+ * `WakeLockService.ts`, `setMapService.svelte.ts`, and `service.ts` all match).
5
+ * Intentionally broad so mis-named services are still caught and reported.
6
+ */
7
+ const SERVICE_FILE = /[sS]ervice\.(svelte\.)?ts$/;
8
+
9
+ /**
10
+ * Matches the required service file naming convention: a PascalCase name
11
+ * (capital first letter) followed by `.service.ts` or `.service.svelte.ts`
12
+ * (e.g. `WakeLock.service.ts`).
13
+ */
14
+ const SERVICE_FILE_NAMING = /^[A-Z][A-Za-z0-9]*\.service\.(svelte\.)?ts$/;
15
+
16
+ /**
17
+ * Excludes test, spec, and mock variants from the gate even when their name
18
+ * would otherwise look like a service file.
19
+ */
20
+ const NON_SOURCE = /\.(test|spec|mock)\./;
21
+
22
+ /**
23
+ * Returns the final path segment of a file path, handling both POSIX and
24
+ * Windows separators.
25
+ *
26
+ * @param filePath Absolute or relative file path from the lint context
27
+ */
28
+ const baseName = (filePath: string): string => {
29
+ const segments = filePath.split(/[\\/]/);
30
+ return segments[segments.length - 1];
31
+ };
32
+
33
+ /**
34
+ * Decides whether a file is in scope for this rule.
35
+ *
36
+ * @param filePath The file path being linted
37
+ */
38
+ export const isServiceFile = (filePath: string): boolean => {
39
+ const name = baseName(filePath);
40
+ return SERVICE_FILE.test(name) && !NON_SOURCE.test(name);
41
+ };
42
+
43
+ /**
44
+ * Decides whether an in-scope service file follows the required
45
+ * `<Name>.service.ts` / `<Name>.service.svelte.ts` naming convention, where
46
+ * `<Name>` is PascalCase (starts with a capital letter).
47
+ *
48
+ * @param filePath The file path being linted
49
+ */
50
+ export const usesServiceFileNaming = (filePath: string): boolean =>
51
+ SERVICE_FILE_NAMING.test(baseName(filePath));
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The single source of truth for this rule's report messages. The rule's `meta`
3
+ * consumes this object, and `ServiceMessageId` types every `context.report`
4
+ * call across the individual validation modules.
5
+ */
6
+ export const messages = {
7
+ fileNaming:
8
+ 'A service file must be named `<Name>.service.ts` or `<Name>.service.svelte.ts`, where `<Name>` starts with a capital letter.',
9
+ classRequired: 'A service file must define exactly one class; none was found.',
10
+ singleClassOnly: 'A service file must define exactly one class.',
11
+ classMustBeNamed: 'A service class must have a name.',
12
+ noTopLevelFunction:
13
+ 'A service file must not define functions outside its class; move this into the class as a private method.',
14
+ noTopLevelVariable:
15
+ 'A service file must not declare variables outside its class; use a private instance or static field instead. The only exception is the singleton `const {{instance}} = new {{className}}();`.',
16
+ instanceName:
17
+ 'The service singleton must be named `{{expected}}` (camelCase of `{{className}}`), not `{{actual}}`.',
18
+ trailingInstanceExport:
19
+ 'Export the singleton via `const {{instance}} = new {{className}}();` then `export default {{instance}};`, not `export default new {{className}}();`.',
20
+ };
21
+
22
+ /**
23
+ * Union of every message id this rule can report.
24
+ */
25
+ export type ServiceMessageId = keyof typeof messages;
@@ -0,0 +1,97 @@
1
+ # `service-file-structure`
2
+
3
+ Enforces the singleton-service file shape for any file the project treats as a
4
+ service.
5
+
6
+ ## Which files are checked
7
+
8
+ A file is treated as a service when its basename ends in `service.ts` or
9
+ `service.svelte.ts`, where only the leading `S` is case-insensitive — so
10
+ `WakeLockService.ts`, `wakeLock.service.ts`, and `setMapService.svelte.ts` all
11
+ qualify. The gate is intentionally broad so that mis-named services are still
12
+ caught and reported (rule 1). Test, spec, and mock variants (`*.test.*`,
13
+ `*.spec.*`, `*.mock.*`) are excluded. All other files are ignored entirely.
14
+
15
+ ## What it enforces
16
+
17
+ 1. **The file name follows the convention** — `<name>.service.ts` or
18
+ `<name>.service.svelte.ts`.
19
+ 2. **Exactly one class**, and it is named.
20
+ 3. **No functions outside the class** — neither `function` declarations nor
21
+ exported ones. Move them in as (private) methods.
22
+ 4. **No variables outside the class** — top-level `const`/`let`/`var` (including
23
+ `export const`) are forbidden; make them private instance or static fields.
24
+ The only exception is the singleton binding (rule 6).
25
+ 5. **The singleton is conventionally named** — `const xService = new XService()`
26
+ must use the class name with a lowercased first letter.
27
+ 6. **A freshly-constructed default export uses the `const`-bound form** —
28
+ `export default new XService();` is rewritten to
29
+ `const xService = new XService();` + `export default xService;`.
30
+
31
+ Exporting the **class itself** as the default (`export default class XService {}`
32
+ or `export default XService`) is allowed and not rewritten, since such a class
33
+ may be intended for extension.
34
+
35
+ ## Autofix
36
+
37
+ - `export default new XService();` (with or without constructor arguments) is
38
+ rewritten to the two-line `const` + `export default` form.
39
+ - The other violations (a non-conforming file name, extra classes, stray
40
+ declarations, a mis-named singleton) are reported without a fix.
41
+
42
+ ## Rule details
43
+
44
+ Examples of **incorrect** code (in a `foo.service.ts` file):
45
+
46
+ ```ts
47
+ // Function and constant outside the class.
48
+ function helper() {}
49
+ const MAX = 3;
50
+
51
+ class FooService {}
52
+ const fooService = new FooService();
53
+ export default fooService;
54
+ ```
55
+
56
+ ```ts
57
+ // Inline default export (auto-fixed).
58
+ class FooService {}
59
+ export default new FooService();
60
+ ```
61
+
62
+ Examples of **correct** code:
63
+
64
+ ```ts
65
+ class FooService {
66
+ private readonly max = 3;
67
+ private helper(): void {}
68
+ }
69
+ const fooService = new FooService();
70
+ export default fooService;
71
+ ```
72
+
73
+ ```ts
74
+ // A service meant to be extended may export its class as the default.
75
+ export default class FooService {}
76
+ ```
77
+
78
+ ## How it is implemented
79
+
80
+ The rule is a thin orchestrator. It gates on the filename (`isServiceFile.ts`),
81
+ resolves the single service class into a shared model (`serviceModel.ts`), then
82
+ runs a list of independent validations from `validations/`:
83
+
84
+ - `validateFileNamingConvention.ts` — file-level; runs even with no valid class
85
+ - `validateNoTopLevelFunctions.ts`
86
+ - `validateNoTopLevelVariables.ts`
87
+ - `validateSingletonName.ts`
88
+ - `validateDefaultExportIsConstBound.ts`
89
+
90
+ Add a new restriction by writing another `validations/validate<Concern>.ts`
91
+ module and listing it in `service-file-structure.ts`.
92
+
93
+ ## When not to use it
94
+
95
+ Disable it for files that are named like services but intentionally export
96
+ something other than a single class. Prefer renaming such files so the gate no
97
+ longer matches.
@@ -0,0 +1,159 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { setupEslintRuleTester } from '../../utils/eslintTestSetup';
3
+ import { serviceFileStructure } from './service-file-structure';
4
+
5
+ setupEslintRuleTester();
6
+
7
+ const ruleTester = new RuleTester({
8
+ languageOptions: {
9
+ parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
10
+ },
11
+ });
12
+
13
+ /**
14
+ * A well-formed service body, parameterized by class name so valid cases stay
15
+ * DRY.
16
+ *
17
+ * @param className The service class name
18
+ */
19
+ const validService = (className: string): string => {
20
+ const instance = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
21
+ return [
22
+ `class ${className} {`,
23
+ ` private helper(): void {}`,
24
+ `}`,
25
+ `const ${instance} = new ${className}();`,
26
+ `export default ${instance};`,
27
+ ].join('\n');
28
+ };
29
+
30
+ ruleTester.run('service-file-structure', serviceFileStructure, {
31
+ valid: [
32
+ // Canonical singleton service.
33
+ { filename: 'WakeLock.service.ts', code: validService('WakeLockService') },
34
+ // Rune-based `.service.svelte.ts` service is in scope and well-formed.
35
+ { filename: 'Timer.service.svelte.ts', code: validService('TimerService') },
36
+ // Exporting the class itself as default is allowed (extensible base).
37
+ {
38
+ filename: 'Foo.service.ts',
39
+ code: `export default class FooService {}`,
40
+ },
41
+ // Exporting the named class as default is also allowed.
42
+ {
43
+ filename: 'Foo.service.ts',
44
+ code: [`class FooService {}`, `export default FooService;`].join('\n'),
45
+ },
46
+ // Out-of-scope file: not a service name, so anything goes.
47
+ { filename: 'helpers.ts', code: `export function stray() {}` },
48
+ // Test files are excluded from the gate.
49
+ { filename: 'Foo.service.test.ts', code: `export function stray() {}` },
50
+ // Mock files are excluded from the gate.
51
+ { filename: 'SetMap.service.mock.ts', code: `export const thing = () => {};` },
52
+ ],
53
+ invalid: [
54
+ // A service file not following the `<Name>.service.ts` naming convention.
55
+ {
56
+ filename: 'FooService.ts',
57
+ code: validService('FooService'),
58
+ errors: [{ messageId: 'fileNaming' }],
59
+ },
60
+ // A `.service.ts` file whose name is not PascalCase (lowercase first letter).
61
+ {
62
+ filename: 'foo.service.ts',
63
+ code: validService('FooService'),
64
+ errors: [{ messageId: 'fileNaming' }],
65
+ },
66
+ // Inline `export default new X()` is rewritten to the two-line form.
67
+ {
68
+ filename: 'Foo.service.ts',
69
+ code: [`class FooService {}`, `export default new FooService();`].join('\n'),
70
+ errors: [{ messageId: 'trailingInstanceExport' }],
71
+ output: [
72
+ `class FooService {}`,
73
+ `const fooService = new FooService();`,
74
+ `export default fooService;`,
75
+ ].join('\n'),
76
+ },
77
+ // Constructor arguments are preserved by the fix.
78
+ {
79
+ filename: 'Foo.service.ts',
80
+ code: [`class FooService {}`, `export default new FooService(1, 2);`].join('\n'),
81
+ errors: [{ messageId: 'trailingInstanceExport' }],
82
+ output: [
83
+ `class FooService {}`,
84
+ `const fooService = new FooService(1, 2);`,
85
+ `export default fooService;`,
86
+ ].join('\n'),
87
+ },
88
+ // Wrong instance name is reported with no safe fix.
89
+ {
90
+ filename: 'Foo.service.ts',
91
+ code: [`class FooService {}`, `const svc = new FooService();`, `export default svc;`].join(
92
+ '\n'
93
+ ),
94
+ errors: [{ messageId: 'instanceName' }],
95
+ output: null,
96
+ },
97
+ // A top-level function declaration is forbidden.
98
+ {
99
+ filename: 'Foo.service.ts',
100
+ code: [
101
+ `class FooService {}`,
102
+ `function helper() {}`,
103
+ `const fooService = new FooService();`,
104
+ `export default fooService;`,
105
+ ].join('\n'),
106
+ errors: [{ messageId: 'noTopLevelFunction' }],
107
+ },
108
+ // A top-level value `const` is forbidden.
109
+ {
110
+ filename: 'Foo.service.ts',
111
+ code: [
112
+ `class FooService {}`,
113
+ `const MAX = 5;`,
114
+ `const fooService = new FooService();`,
115
+ `export default fooService;`,
116
+ ].join('\n'),
117
+ errors: [{ messageId: 'noTopLevelVariable' }],
118
+ },
119
+ // A top-level arrow-function `const` is forbidden.
120
+ {
121
+ filename: 'Foo.service.ts',
122
+ code: [
123
+ `class FooService {}`,
124
+ `const helper = () => {};`,
125
+ `const fooService = new FooService();`,
126
+ `export default fooService;`,
127
+ ].join('\n'),
128
+ errors: [{ messageId: 'noTopLevelVariable' }],
129
+ },
130
+ // A top-level named `export const` is forbidden.
131
+ {
132
+ filename: 'Foo.service.ts',
133
+ code: [
134
+ `class FooService {}`,
135
+ `export const value = 1;`,
136
+ `const fooService = new FooService();`,
137
+ `export default fooService;`,
138
+ ].join('\n'),
139
+ errors: [{ messageId: 'noTopLevelVariable' }],
140
+ },
141
+ // More than one class is not allowed.
142
+ {
143
+ filename: 'Foo.service.ts',
144
+ code: [
145
+ `class AService {}`,
146
+ `class BService {}`,
147
+ `const aService = new AService();`,
148
+ `export default aService;`,
149
+ ].join('\n'),
150
+ errors: [{ messageId: 'singleClassOnly' }],
151
+ },
152
+ // No class at all.
153
+ {
154
+ filename: 'Foo.service.ts',
155
+ code: `export const value = 1;`,
156
+ errors: [{ messageId: 'classRequired' }],
157
+ },
158
+ ],
159
+ });
@@ -0,0 +1,61 @@
1
+ import { createRule } from '../../utils/createRule';
2
+ import { isServiceFile } from './isServiceFile';
3
+ import { messages } from './messages';
4
+ import { resolveServiceClass, type ServiceValidation } from './serviceModel';
5
+ import { validateDefaultExportIsConstBound } from './validations/validateDefaultExportIsConstBound';
6
+ import { validateFileNamingConvention } from './validations/validateFileNamingConvention';
7
+ import { validateNoTopLevelFunctions } from './validations/validateNoTopLevelFunctions';
8
+ import { validateNoTopLevelVariables } from './validations/validateNoTopLevelVariables';
9
+ import { validateSingletonName } from './validations/validateSingletonName';
10
+
11
+ /**
12
+ * Validations that operate on the resolved single-class model, in order. Each
13
+ * lives in its own `validations/validate<Concern>.ts` module and reports
14
+ * against the shared model. Add a new restriction by writing another module and
15
+ * listing it here.
16
+ */
17
+ const validations: ServiceValidation[] = [
18
+ validateNoTopLevelFunctions,
19
+ validateNoTopLevelVariables,
20
+ validateSingletonName,
21
+ validateDefaultExportIsConstBound,
22
+ ];
23
+
24
+ /**
25
+ * Enforces the singleton-service file shape according to the standards that seem to help the most
26
+ * across multiple code-bases that Aneuhold works with.
27
+ */
28
+ export const serviceFileStructure = createRule({
29
+ name: 'service-file-structure',
30
+ meta: {
31
+ type: 'problem',
32
+ fixable: 'code',
33
+ docs: {
34
+ description:
35
+ 'A service file must contain exactly one named, JSDoc-documented class with no top-level functions or variables, and must export a fresh instance via a `const`-bound singleton rather than inline.',
36
+ },
37
+ messages,
38
+ schema: [],
39
+ },
40
+ defaultOptions: [],
41
+ create(context) {
42
+ if (!isServiceFile(context.filename)) {
43
+ return {};
44
+ }
45
+
46
+ // Evaluate when the node is left and all it's children have already been processed.
47
+ return {
48
+ 'Program:exit'(program) {
49
+ validateFileNamingConvention(context);
50
+
51
+ const model = resolveServiceClass(context, program);
52
+ if (!model) {
53
+ return;
54
+ }
55
+ for (const validate of validations) {
56
+ validate(context, model);
57
+ }
58
+ },
59
+ };
60
+ },
61
+ });