@aneuhold/eslint-config 2.0.4 → 3.0.0
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 +15 -54
- package/package.json +33 -27
- package/src/{react-next-config.js → configs/react-next-config.ts} +6 -5
- package/src/{svelte-config.js → configs/svelte-config.ts} +2 -2
- package/src/{ts-lib-config.js → configs/ts-lib-config.ts} +17 -9
- package/src/rules/index.ts +45 -0
- package/src/rules/no-private-modifier/buildClassFixes.ts +39 -0
- package/src/rules/no-private-modifier/classFrame.ts +143 -0
- package/src/rules/no-private-modifier/classReports.ts +57 -0
- package/src/rules/no-private-modifier/no-private-modifier.md +72 -0
- package/src/rules/no-private-modifier/no-private-modifier.test.ts +109 -0
- package/src/rules/no-private-modifier/no-private-modifier.ts +132 -0
- package/src/rules/no-private-modifier/types.ts +42 -0
- package/src/rules/service-file-structure/isServiceFile.ts +51 -0
- package/src/rules/service-file-structure/messages.ts +25 -0
- package/src/rules/service-file-structure/service-file-structure.md +97 -0
- package/src/rules/service-file-structure/service-file-structure.test.ts +159 -0
- package/src/rules/service-file-structure/service-file-structure.ts +61 -0
- package/src/rules/service-file-structure/serviceModel.ts +134 -0
- package/src/rules/service-file-structure/validations/validateDefaultExportIsConstBound.ts +40 -0
- package/src/rules/service-file-structure/validations/validateFileNamingConvention.ts +18 -0
- package/src/rules/service-file-structure/validations/validateNoTopLevelFunctions.ts +23 -0
- package/src/rules/service-file-structure/validations/validateNoTopLevelVariables.ts +33 -0
- package/src/rules/service-file-structure/validations/validateSingletonName.ts +26 -0
- package/src/utils/createRule.ts +12 -0
- package/src/utils/eslintTestSetup.ts +19 -0
- /package/src/{angular-config.js → configs/angular-config.ts} +0 -0
- /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
|
+
};
|
|
File without changes
|
|
File without changes
|