@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,134 @@
1
+ import { AST_NODE_TYPES, type TSESLint, type TSESTree } from '@typescript-eslint/utils';
2
+ import type { ServiceMessageId } from './messages';
3
+
4
+ /**
5
+ * The rule context, narrowed to this rule's message ids and (empty) options, so
6
+ * every validation module gets fully-typed `context.report` calls.
7
+ */
8
+ export type ServiceRuleContext = Readonly<TSESLint.RuleContext<ServiceMessageId, []>>;
9
+
10
+ /**
11
+ * The shared model the orchestrator builds once and hands to every validation:
12
+ * the file's single service class plus the derived names and program body the
13
+ * validations operate on.
14
+ */
15
+ export type ServiceModel = {
16
+ body: TSESTree.ProgramStatement[];
17
+ /** The class declaration itself. */
18
+ classNode: TSESTree.ClassDeclaration;
19
+ /** The statement carrying the class — itself, or its `export` wrapper. */
20
+ outer: TSESTree.ProgramStatement;
21
+ className: string;
22
+ /** Conventional singleton instance name: the class name, first letter lowercased. */
23
+ instance: string;
24
+ };
25
+
26
+ /**
27
+ * A validation step: inspects the shared model and reports any violations.
28
+ */
29
+ export type ServiceValidation = (context: ServiceRuleContext, model: ServiceModel) => void;
30
+
31
+ /**
32
+ * A file-level validation step that depends only on the file itself (e.g. its
33
+ * name), so it runs even when there is no resolvable service class.
34
+ */
35
+ export type ServiceFileValidation = (context: ServiceRuleContext) => void;
36
+
37
+ /**
38
+ * Describes a top-level `const x = new SomeClass()` binding: whether it is the
39
+ * exempt singleton for this service, plus the bound name and declarator for
40
+ * follow-up checks.
41
+ */
42
+ export type SingletonInfo = {
43
+ isSingleton: boolean;
44
+ name?: string;
45
+ declarator?: TSESTree.LetOrConstOrVarDeclarator;
46
+ };
47
+
48
+ /**
49
+ * Derives the conventional singleton instance name from a class name by
50
+ * lowercasing the first character (e.g. `WakeLockService` -> `wakeLockService`).
51
+ *
52
+ * @param className The service class name
53
+ */
54
+ export const instanceNameFor = (className: string): string =>
55
+ `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
56
+
57
+ /**
58
+ * Inspects a top-level variable declaration and reports whether it is the one
59
+ * exempt singleton binding `const <name> = new <className>()`.
60
+ *
61
+ * @param node The variable declaration statement
62
+ * @param className The file's single service class name
63
+ */
64
+ export const singletonInfo = (
65
+ node: TSESTree.VariableDeclaration,
66
+ className: string
67
+ ): SingletonInfo => {
68
+ if (node.kind !== 'const' || node.declarations.length !== 1) {
69
+ return { isSingleton: false };
70
+ }
71
+ const [declarator] = node.declarations;
72
+ const isSingleton =
73
+ declarator.id.type === AST_NODE_TYPES.Identifier &&
74
+ declarator.init?.type === AST_NODE_TYPES.NewExpression &&
75
+ declarator.init.callee.type === AST_NODE_TYPES.Identifier &&
76
+ declarator.init.callee.name === className;
77
+ if (!isSingleton || declarator.id.type !== AST_NODE_TYPES.Identifier) {
78
+ return { isSingleton: false };
79
+ }
80
+ return { isSingleton: true, name: declarator.id.name, declarator };
81
+ };
82
+
83
+ /**
84
+ * Collects the file's top-level classes (bare or exported) and, if there is
85
+ * exactly one named class, returns the shared model. Otherwise reports the
86
+ * blocking class-shape problem and returns `null` so no further validation runs.
87
+ *
88
+ * @param context The rule context
89
+ * @param program The program node
90
+ */
91
+ export const resolveServiceClass = (
92
+ context: ServiceRuleContext,
93
+ program: TSESTree.Program
94
+ ): ServiceModel | null => {
95
+ const classes: { node: TSESTree.ClassDeclaration; outer: TSESTree.ProgramStatement }[] = [];
96
+ for (const statement of program.body) {
97
+ if (statement.type === AST_NODE_TYPES.ClassDeclaration) {
98
+ classes.push({ node: statement, outer: statement });
99
+ } else if (
100
+ (statement.type === AST_NODE_TYPES.ExportNamedDeclaration ||
101
+ statement.type === AST_NODE_TYPES.ExportDefaultDeclaration) &&
102
+ statement.declaration?.type === AST_NODE_TYPES.ClassDeclaration
103
+ ) {
104
+ classes.push({ node: statement.declaration, outer: statement });
105
+ }
106
+ }
107
+
108
+ if (classes.length === 0) {
109
+ context.report({ node: program, messageId: 'classRequired' });
110
+ return null;
111
+ }
112
+
113
+ if (classes.length > 1) {
114
+ for (const extra of classes.slice(1)) {
115
+ context.report({ node: extra.node, messageId: 'singleClassOnly' });
116
+ }
117
+ return null;
118
+ }
119
+
120
+ const { node: classNode, outer } = classes[0];
121
+ const className = classNode.id?.name;
122
+ if (!className) {
123
+ context.report({ node: classNode, messageId: 'classMustBeNamed' });
124
+ return null;
125
+ }
126
+
127
+ return {
128
+ body: program.body,
129
+ classNode,
130
+ outer,
131
+ className,
132
+ instance: instanceNameFor(className),
133
+ };
134
+ };
@@ -0,0 +1,40 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2
+ import type { ServiceValidation } from '../serviceModel';
3
+
4
+ /**
5
+ * Flags an inline `export default new XService();` and rewrites it to the
6
+ * `const`-bound singleton form. Exporting the class itself as the default
7
+ * (`export default class XService {}` or `export default XService`) is left
8
+ * alone, since such a class may be intended for extension.
9
+ *
10
+ * @param context The rule context
11
+ * @param model The shared service model
12
+ */
13
+ export const validateDefaultExportIsConstBound: ServiceValidation = (context, model) => {
14
+ const defaultExport = model.body.find(
15
+ (statement): statement is TSESTree.ExportDefaultDeclaration =>
16
+ statement.type === AST_NODE_TYPES.ExportDefaultDeclaration
17
+ );
18
+
19
+ const decl = defaultExport?.declaration;
20
+ const isInlineInstance =
21
+ decl?.type === AST_NODE_TYPES.NewExpression &&
22
+ decl.callee.type === AST_NODE_TYPES.Identifier &&
23
+ decl.callee.name === model.className;
24
+
25
+ if (!defaultExport || !isInlineInstance) {
26
+ return;
27
+ }
28
+
29
+ const { instance, className } = model;
30
+ context.report({
31
+ node: defaultExport,
32
+ messageId: 'trailingInstanceExport',
33
+ data: { instance, className },
34
+ fix: (fixer) =>
35
+ fixer.replaceText(
36
+ defaultExport,
37
+ `const ${instance} = ${context.sourceCode.getText(decl)};\nexport default ${instance};`
38
+ ),
39
+ });
40
+ };
@@ -0,0 +1,18 @@
1
+ import { usesServiceFileNaming } from '../isServiceFile';
2
+ import type { ServiceFileValidation } from '../serviceModel';
3
+
4
+ /**
5
+ * Requires an in-scope service file to follow the `<Name>.service.ts` /
6
+ * `<Name>.service.svelte.ts` naming convention, where `<Name>` is PascalCase
7
+ * (starts with a capital letter). Reported at the top of the file since the
8
+ * violation is the file name itself, not any particular node.
9
+ *
10
+ * @param context The rule context
11
+ */
12
+ export const validateFileNamingConvention: ServiceFileValidation = (context) => {
13
+ if (usesServiceFileNaming(context.filename)) {
14
+ return;
15
+ }
16
+
17
+ context.report({ loc: { line: 1, column: 0 }, messageId: 'fileNaming' });
18
+ };
@@ -0,0 +1,23 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import type { ServiceValidation } from '../serviceModel';
3
+
4
+ /**
5
+ * Forbids functions declared at the top level of a service file, whether bare
6
+ * (`function f() {}`) or exported (`export function f() {}`). Behavior belongs
7
+ * inside the class as a (private) method.
8
+ *
9
+ * @param context The rule context
10
+ * @param model The shared service model
11
+ */
12
+ export const validateNoTopLevelFunctions: ServiceValidation = (context, model) => {
13
+ for (const statement of model.body) {
14
+ if (statement.type === AST_NODE_TYPES.FunctionDeclaration) {
15
+ context.report({ node: statement, messageId: 'noTopLevelFunction' });
16
+ } else if (
17
+ statement.type === AST_NODE_TYPES.ExportNamedDeclaration &&
18
+ statement.declaration?.type === AST_NODE_TYPES.FunctionDeclaration
19
+ ) {
20
+ context.report({ node: statement, messageId: 'noTopLevelFunction' });
21
+ }
22
+ }
23
+ };
@@ -0,0 +1,33 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import { type ServiceValidation, singletonInfo } from '../serviceModel';
3
+
4
+ /**
5
+ * Forbids variables declared at the top level of a service file — both exported
6
+ * named declarations (`export const x = ...`) and bare ones. Constants belong
7
+ * inside the class as private instance or static fields. The sole exception is
8
+ * the singleton binding `const xService = new XService();`, whose name is
9
+ * validated separately.
10
+ *
11
+ * @param context The rule context
12
+ * @param model The shared service model
13
+ */
14
+ export const validateNoTopLevelVariables: ServiceValidation = (context, model) => {
15
+ const data = { instance: model.instance, className: model.className };
16
+
17
+ for (const statement of model.body) {
18
+ if (
19
+ statement.type === AST_NODE_TYPES.ExportNamedDeclaration &&
20
+ statement.declaration?.type === AST_NODE_TYPES.VariableDeclaration
21
+ ) {
22
+ context.report({ node: statement, messageId: 'noTopLevelVariable', data });
23
+ continue;
24
+ }
25
+
26
+ if (
27
+ statement.type === AST_NODE_TYPES.VariableDeclaration &&
28
+ !singletonInfo(statement, model.className).isSingleton
29
+ ) {
30
+ context.report({ node: statement, messageId: 'noTopLevelVariable', data });
31
+ }
32
+ }
33
+ };
@@ -0,0 +1,26 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
+ import { type ServiceValidation, singletonInfo } from '../serviceModel';
3
+
4
+ /**
5
+ * Requires the singleton binding `const <name> = new <ClassName>()` to use the
6
+ * conventional instance name — the class name with a lowercased first letter.
7
+ *
8
+ * @param context The rule context
9
+ * @param model The shared service model
10
+ */
11
+ export const validateSingletonName: ServiceValidation = (context, model) => {
12
+ for (const statement of model.body) {
13
+ if (statement.type !== AST_NODE_TYPES.VariableDeclaration) {
14
+ continue;
15
+ }
16
+
17
+ const info = singletonInfo(statement, model.className);
18
+ if (info.isSingleton && info.name !== model.instance) {
19
+ context.report({
20
+ node: info.declarator ?? statement,
21
+ messageId: 'instanceName',
22
+ data: { expected: model.instance, className: model.className, actual: info.name ?? '' },
23
+ });
24
+ }
25
+ }
26
+ };
@@ -0,0 +1,12 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+
3
+ const REPO_URL = 'https://github.com/aneuhold/eslint-config';
4
+
5
+ /**
6
+ * Shared `RuleCreator` for this package's custom ESLint rules. Each rule's
7
+ * documentation URL points at the rule's own markdown file, which lives next to
8
+ * its implementation at `src/rules/<rule-name>/<rule-name>.md`.
9
+ */
10
+ export const createRule = ESLintUtils.RuleCreator(
11
+ (name) => `${REPO_URL}/blob/main/src/rules/${name}/${name}.md`
12
+ );
@@ -0,0 +1,19 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { afterAll, describe, it } from 'vitest';
3
+
4
+ /**
5
+ * Wires `@typescript-eslint/rule-tester`'s `RuleTester` up to Vitest's test
6
+ * hooks. `RuleTester` calls `describe`/`it`/`afterAll` to register its
7
+ * generated cases, but it only reads them off its own static properties — and
8
+ * Vitest does not expose these as globals unless `globals: true` is set. So
9
+ * they have to be assigned explicitly before any `RuleTester` is constructed.
10
+ *
11
+ * See the "Vitest" section of the rule-tester docs:
12
+ * https://typescript-eslint.io/packages/rule-tester/#vitest
13
+ */
14
+ export const setupEslintRuleTester = (): void => {
15
+ RuleTester.afterAll = afterAll;
16
+ RuleTester.describe = describe;
17
+ RuleTester.it = it;
18
+ RuleTester.itOnly = it.only;
19
+ };