@aneuhold/eslint-config 2.0.5 → 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 +25 -19
- 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} +4 -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
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@ Personal ESLint Configuration
|
|
|
4
4
|
|
|
5
5
|
## Notes on Architecture
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- This library is authored in **raw TypeScript with no build step**. The configs and rule source ship as `.ts`, and each consumer's ESLint transpiles them on the fly via [jiti](https://github.com/unjs/jiti).
|
|
8
8
|
- All dependencies should be able to be only defined in this repo outside of ESLint and Prettier, as those will be brought in to consuming repos as peer deps.
|
|
9
9
|
- In order for there not to be crossover between configuration dependencies, each config should be brought in as the full path to the configuration. For example:
|
|
10
10
|
|
|
11
|
-
```
|
|
12
|
-
import tsLibConfig from '@aneuhold/eslint-config/src/ts-lib-config
|
|
11
|
+
```ts
|
|
12
|
+
import tsLibConfig from '@aneuhold/eslint-config/src/configs/ts-lib-config';
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
@@ -45,66 +45,27 @@ Make sure to add the following settings to VSCode settings.json:
|
|
|
45
45
|
|
|
46
46
|
Then add a prettier file, such as the one in this repo [here](.prettierrc.js).
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
The config sources are `.ts`, so add an `eslint.config.ts` that spreads in the
|
|
49
|
+
config for your stack. ESLint transpiles both your config and the package's
|
|
50
|
+
sources on the fly via jiti — no build step.
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
```ts
|
|
53
|
+
import tsLibConfig from '@aneuhold/eslint-config/src/configs/ts-lib-config';
|
|
51
54
|
|
|
52
|
-
```js
|
|
53
|
-
const config = (async () => (await import('./eslint.config.mjs')).default)();
|
|
54
|
-
|
|
55
|
-
module.exports = config;
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Add `eslint.config.mjs` like so:
|
|
59
|
-
|
|
60
|
-
```js
|
|
61
|
-
// @ts-check
|
|
62
|
-
|
|
63
|
-
import tsLibConfig from '@aneuhold/eslint-config/src/ts-lib-config.js';
|
|
64
|
-
|
|
65
|
-
/** @type {import('@typescript-eslint/utils').TSESLint.FlatConfig.ConfigArray} */
|
|
66
55
|
export default [
|
|
67
56
|
...tsLibConfig,
|
|
68
57
|
{
|
|
69
|
-
//
|
|
70
|
-
},
|
|
71
|
-
];
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### Setup for `ESNext` (ES Modules)
|
|
75
|
-
|
|
76
|
-
Add `eslint.config.js` like so:
|
|
77
|
-
|
|
78
|
-
```js
|
|
79
|
-
// @ts-check
|
|
80
|
-
|
|
81
|
-
import svelteConfig from '@aneuhold/eslint-config/src/svelte-config.js';
|
|
82
|
-
|
|
83
|
-
/** @type {import('@typescript-eslint/utils').TSESLint.FlatConfig.ConfigArray} */
|
|
84
|
-
export default [
|
|
85
|
-
...svelteConfig,
|
|
86
|
-
{
|
|
87
|
-
// other override settings. e.g. for `files: ['**/*.test.*']`
|
|
58
|
+
// your overrides, e.g. for `files: ['**/*.test.*']`
|
|
88
59
|
},
|
|
89
60
|
];
|
|
90
61
|
```
|
|
91
62
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
If you have specific configs for different folders, make sure to exclude those folders in the top-level config! For example:
|
|
63
|
+
Swap `ts-lib-config` for whichever config matches the project: `svelte-config`,
|
|
64
|
+
`react-config`, `react-next-config`, or `angular-config`.
|
|
95
65
|
|
|
96
|
-
|
|
97
|
-
|
|
66
|
+
**Monorepo:** if nested folders have their own configs, add an `ignores` entry
|
|
67
|
+
so the top-level config doesn't also lint them:
|
|
98
68
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
...tsLibConfig,
|
|
102
|
-
{
|
|
103
|
-
// other override settings. e.g. for `files: ['**/*.test.*']`
|
|
104
|
-
rules: {},
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
ignores: ['**/lib', 'svelte', 'react'],
|
|
108
|
-
},
|
|
109
|
-
];
|
|
69
|
+
```ts
|
|
70
|
+
{ ignores: ['**/lib', 'svelte', 'react'] }
|
|
110
71
|
```
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aneuhold/eslint-config",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Main ESLint Configuration for personal projects",
|
|
5
|
-
"main": "./src/ts-lib-config.
|
|
5
|
+
"main": "./src/configs/ts-lib-config.ts",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"dev": "nodemon -e
|
|
8
|
+
"dev": "nodemon -e ts --exec \"local-npm publish\"",
|
|
9
9
|
"unpub": "local-npm unpublish",
|
|
10
10
|
"pushpub": "pnpm version patch && git push",
|
|
11
11
|
"upgrade:all": "pnpm update --latest",
|
|
12
|
-
"
|
|
12
|
+
"check": "tsc --noEmit",
|
|
13
|
+
"lint": "pnpm eslint",
|
|
14
|
+
"test": "vitest run"
|
|
13
15
|
},
|
|
14
16
|
"type": "module",
|
|
15
17
|
"repository": {
|
|
@@ -36,32 +38,36 @@
|
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
40
|
"@eslint/js": "^10.0.1",
|
|
39
|
-
"@next/eslint-plugin-next": "^16.2.
|
|
41
|
+
"@next/eslint-plugin-next": "^16.2.7",
|
|
40
42
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
|
41
43
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
|
42
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
43
|
-
"@typescript-eslint/parser": "^8.
|
|
44
|
-
"
|
|
44
|
+
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
|
45
|
+
"@typescript-eslint/parser": "^8.60.1",
|
|
46
|
+
"@typescript-eslint/utils": "^8.60.1",
|
|
47
|
+
"angular-eslint": "^21.4.0",
|
|
45
48
|
"eslint-config-prettier": "^10.1.8",
|
|
46
49
|
"eslint-plugin-import": "^2.32.0",
|
|
47
|
-
"eslint-plugin-jsdoc": "^
|
|
50
|
+
"eslint-plugin-jsdoc": "^63.0.1",
|
|
48
51
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
|
49
|
-
"eslint-plugin-prettier": "^5.5.
|
|
52
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
50
53
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
51
54
|
"eslint-plugin-react-refresh": "^0.5.2",
|
|
52
55
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
53
|
-
"eslint-plugin-svelte": "^3.
|
|
56
|
+
"eslint-plugin-svelte": "^3.19.0",
|
|
54
57
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
55
|
-
"globals": "^17.
|
|
56
|
-
"prettier-plugin-svelte": "^
|
|
57
|
-
"typescript-eslint": "^8.
|
|
58
|
+
"globals": "^17.6.0",
|
|
59
|
+
"prettier-plugin-svelte": "^4.1.0",
|
|
60
|
+
"typescript-eslint": "^8.60.1"
|
|
58
61
|
},
|
|
59
62
|
"devDependencies": {
|
|
60
|
-
"@aneuhold/local-npm-registry": "^0.2.
|
|
61
|
-
"@types/node": "^25.
|
|
62
|
-
"eslint": "^
|
|
63
|
+
"@aneuhold/local-npm-registry": "^0.2.32",
|
|
64
|
+
"@types/node": "^25.9.1",
|
|
65
|
+
"@typescript-eslint/rule-tester": "^8.60.1",
|
|
66
|
+
"eslint": "^10.4.1",
|
|
67
|
+
"jiti": "^2.7.0",
|
|
63
68
|
"prettier": "^3.8.3",
|
|
64
|
-
"svelte": "^5.
|
|
65
|
-
"typescript": "^6.0.3"
|
|
69
|
+
"svelte": "^5.56.2",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"vitest": "^4.1.4"
|
|
66
72
|
}
|
|
67
73
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import nextPlugin from '@next/eslint-plugin-next';
|
|
2
2
|
import { defineConfig } from 'eslint/config';
|
|
3
|
-
import reactConfig from './react-config
|
|
4
|
-
|
|
5
|
-
// Ya, this is kind of weird, but if you dig into the actual code in the next plugin, this is correct.
|
|
6
|
-
const { flatConfig } = nextPlugin;
|
|
3
|
+
import reactConfig from './react-config';
|
|
7
4
|
|
|
8
5
|
export default defineConfig(
|
|
9
6
|
...reactConfig,
|
|
10
7
|
{
|
|
11
8
|
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
12
|
-
|
|
9
|
+
// @next/eslint-plugin-next is not typed, so `flatConfig` resolves as an
|
|
10
|
+
// unresolvable type.
|
|
11
|
+
// @ts-expect-error - `flatConfig` exists at runtime but isn't in the plugin's types.
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
13
|
+
extends: [nextPlugin.flatConfig.recommended],
|
|
13
14
|
rules: {
|
|
14
15
|
// Allow export names that are used by Next.js in the app directory.
|
|
15
16
|
'react-refresh/only-export-components': [
|
|
@@ -6,6 +6,7 @@ import eslintPluginSvelte from 'eslint-plugin-svelte';
|
|
|
6
6
|
import { defineConfig } from 'eslint/config';
|
|
7
7
|
import globals from 'globals';
|
|
8
8
|
import tseslint from 'typescript-eslint';
|
|
9
|
+
import { aneuholdRules } from '../rules';
|
|
9
10
|
|
|
10
11
|
// Shared extraFileExtensions constant to avoid project service reloads.
|
|
11
12
|
// See: https://typescript-eslint.io/troubleshooting/typed-linting/performance/#changes-to-extrafileextensions-with-projectservice
|
|
@@ -21,6 +22,7 @@ export default defineConfig(
|
|
|
21
22
|
...tseslint.configs.strictTypeChecked,
|
|
22
23
|
jsdoc.configs['flat/recommended-typescript'],
|
|
23
24
|
eslintPluginPrettierRecommended,
|
|
25
|
+
aneuholdRules,
|
|
24
26
|
],
|
|
25
27
|
plugins: {
|
|
26
28
|
'simple-import-sort': simpleImportSort,
|
|
@@ -143,9 +145,7 @@ export default defineConfig(
|
|
|
143
145
|
// eslint-plugin-svelte's flat/recommended already includes base config
|
|
144
146
|
// which sets up svelte-eslint-parser and the svelte processor.
|
|
145
147
|
// flat/prettier disables svelte rules that conflict with Prettier.
|
|
146
|
-
// @ts-expect-error - eslint-plugin-svelte is not typed
|
|
147
148
|
...eslintPluginSvelte.configs['flat/recommended'],
|
|
148
|
-
// @ts-expect-error - eslint-plugin-svelte is not typed
|
|
149
149
|
...eslintPluginSvelte.configs['flat/prettier'],
|
|
150
150
|
|
|
151
151
|
// 4. TypeScript integration for Svelte files: tell svelte-eslint-parser
|
|
@@ -4,8 +4,9 @@ import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
|
|
4
4
|
import { defineConfig } from 'eslint/config';
|
|
5
5
|
import globals from 'globals';
|
|
6
6
|
import tseslint from 'typescript-eslint';
|
|
7
|
+
import { aneuholdRules } from '../rules';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
export default defineConfig(
|
|
9
10
|
{
|
|
10
11
|
files: ['**/*.js', '**/*.mjs', '**/*.ts'],
|
|
11
12
|
extends: [
|
|
@@ -13,6 +14,7 @@ const defaultConfig = defineConfig(
|
|
|
13
14
|
...tseslint.configs.strictTypeChecked,
|
|
14
15
|
jsdoc.configs['flat/recommended-typescript'],
|
|
15
16
|
eslintPluginPrettierRecommended,
|
|
17
|
+
aneuholdRules,
|
|
16
18
|
],
|
|
17
19
|
languageOptions: {
|
|
18
20
|
parser: tseslint.parser,
|
|
@@ -70,15 +72,8 @@ const defaultConfig = defineConfig(
|
|
|
70
72
|
// disable type-aware linting on JS files
|
|
71
73
|
files: ['**/*.js', '**/*.mjs'],
|
|
72
74
|
extends: [tseslint.configs.disableTypeChecked],
|
|
73
|
-
}
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
export default defineConfig(
|
|
77
|
-
...defaultConfig,
|
|
78
|
-
{
|
|
79
|
-
// other override settings. e.g. for `files: ['**/*.test.*']`
|
|
80
75
|
},
|
|
81
76
|
{
|
|
82
77
|
ignores: ['.yarn', 'build', 'lib', 'node_modules', 'eslint.config.js', '**/.DS_Store'],
|
|
83
|
-
}
|
|
78
|
+
}
|
|
84
79
|
);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Linter } from 'eslint';
|
|
2
|
+
import { noPrivateModifier } from './no-private-modifier/no-private-modifier';
|
|
3
|
+
import { serviceFileStructure } from './service-file-structure/service-file-structure';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The runtime `aneuhold` plugin object.
|
|
7
|
+
*/
|
|
8
|
+
const aneuholdPlugin = {
|
|
9
|
+
meta: { name: 'aneuhold' },
|
|
10
|
+
rules: {
|
|
11
|
+
'no-private-modifier': noPrivateModifier,
|
|
12
|
+
'service-file-structure': serviceFileStructure,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The same plugin object handed out through a deliberately narrow type that
|
|
18
|
+
* hides `rules`. This is what lets the fragment below be accepted by
|
|
19
|
+
* `defineConfig`: ESLint core's strict `RuleDefinition` type would otherwise
|
|
20
|
+
* reject rules built with typescript-eslint's `RuleCreator` (whose looser
|
|
21
|
+
* `RuleModule` type isn't assignable — typescript-eslint#10396 / eslint#19155).
|
|
22
|
+
* By erasing `rules` from the public type, core never type-checks them, while
|
|
23
|
+
* the runtime object still registers them normally.
|
|
24
|
+
*
|
|
25
|
+
* This mirrors how typescript-eslint widens its own public `plugin`/`configs`
|
|
26
|
+
* types to satisfy both `defineConfig()` and `tseslint.config()`:
|
|
27
|
+
* https://github.com/typescript-eslint/typescript-eslint/blob/v8.60.1/packages/typescript-eslint/src/compatibility-types.ts
|
|
28
|
+
*/
|
|
29
|
+
const compatiblePlugin: { meta: { name: string } } = aneuholdPlugin;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Flat-config fragment that registers the `aneuhold` plugin and enables its
|
|
33
|
+
* rules. Drop it into a config block's `extends` array (or spread it into a
|
|
34
|
+
* standalone config object); either way it inherits the host block's `files`
|
|
35
|
+
* scope.
|
|
36
|
+
*/
|
|
37
|
+
export const aneuholdRules: Linter.Config = {
|
|
38
|
+
plugins: {
|
|
39
|
+
aneuhold: compatiblePlugin,
|
|
40
|
+
},
|
|
41
|
+
rules: {
|
|
42
|
+
'aneuhold/no-private-modifier': 'error',
|
|
43
|
+
'aneuhold/service-file-structure': 'error',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
import { type FixTarget } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds every edit needed to convert a class's fixable members in one go:
|
|
6
|
+
* dropping each `private` modifier, prefixing each name with `#`, and prefixing
|
|
7
|
+
* each rewritable reference with `#`. Bundling the whole class into one fix lets
|
|
8
|
+
* it convert in a single pass even when a member's references sit past another
|
|
9
|
+
* member being converted.
|
|
10
|
+
*
|
|
11
|
+
* @param fixer The ESLint fixer
|
|
12
|
+
* @param sourceCode The source code object
|
|
13
|
+
* @param targets The members to convert, with their references
|
|
14
|
+
*/
|
|
15
|
+
export const buildClassFixes = (
|
|
16
|
+
fixer: TSESLint.RuleFixer,
|
|
17
|
+
sourceCode: Readonly<TSESLint.SourceCode>,
|
|
18
|
+
targets: FixTarget[]
|
|
19
|
+
): TSESLint.RuleFix[] => {
|
|
20
|
+
const fixes: TSESLint.RuleFix[] = [];
|
|
21
|
+
|
|
22
|
+
for (const { member, refs } of targets) {
|
|
23
|
+
const privateToken = sourceCode.getTokens(member).find((token) => token.value === 'private');
|
|
24
|
+
const afterPrivate = privateToken && sourceCode.getTokenAfter(privateToken);
|
|
25
|
+
if (privateToken && afterPrivate) {
|
|
26
|
+
fixes.push(fixer.removeRange([privateToken.range[0], afterPrivate.range[0]]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fixes.push(fixer.insertTextBefore(member.key, '#'));
|
|
30
|
+
for (const ref of refs) {
|
|
31
|
+
fixes.push(fixer.insertTextBefore(ref.property, '#'));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ESLint merges a report's edits by range and requires them ordered and
|
|
36
|
+
// non-overlapping.
|
|
37
|
+
fixes.sort((a, b) => a.range[0] - b.range[0]);
|
|
38
|
+
return fixes;
|
|
39
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
import { type ClassFrame, type ClassNode } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a fresh frame for a class, scanning its members up-front for the
|
|
6
|
+
* private names eligible to convert and the `#names` already in use.
|
|
7
|
+
*
|
|
8
|
+
* @param classNode The class declaration or expression
|
|
9
|
+
*/
|
|
10
|
+
export const createClassFrame = (classNode: ClassNode): ClassFrame => {
|
|
11
|
+
const candidateNames = new Set<string>();
|
|
12
|
+
const existingPrivateNames = new Set<string>();
|
|
13
|
+
|
|
14
|
+
for (const member of classNode.body.body) {
|
|
15
|
+
if (
|
|
16
|
+
member.type !== AST_NODE_TYPES.PropertyDefinition &&
|
|
17
|
+
member.type !== AST_NODE_TYPES.MethodDefinition &&
|
|
18
|
+
member.type !== AST_NODE_TYPES.AccessorProperty
|
|
19
|
+
) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (member.key.type === AST_NODE_TYPES.PrivateIdentifier) {
|
|
23
|
+
existingPrivateNames.add(member.key.name);
|
|
24
|
+
} else if (
|
|
25
|
+
!member.computed &&
|
|
26
|
+
member.key.type === AST_NODE_TYPES.Identifier &&
|
|
27
|
+
member.accessibility === 'private'
|
|
28
|
+
) {
|
|
29
|
+
candidateNames.add(member.key.name);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
classNode,
|
|
35
|
+
className: classNode.id?.name ?? null,
|
|
36
|
+
candidateNames,
|
|
37
|
+
existingPrivateNames,
|
|
38
|
+
thisRefs: new Map(),
|
|
39
|
+
staticRefs: new Map(),
|
|
40
|
+
blocked: new Set(),
|
|
41
|
+
rebindDepth: 0,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Records a member access against the frame, bucketing it as a rewritable
|
|
47
|
+
* `this.name` / `ClassName.name` reference or blocking the name when the access
|
|
48
|
+
* has no safe `#` equivalent.
|
|
49
|
+
*
|
|
50
|
+
* @param frame The enclosing class's frame
|
|
51
|
+
* @param node The member-access expression
|
|
52
|
+
* @param thisIsInstance Whether `this` here refers to the class instance/itself
|
|
53
|
+
*/
|
|
54
|
+
export const classifyMemberAccess = (
|
|
55
|
+
frame: ClassFrame,
|
|
56
|
+
node: TSESTree.MemberExpression,
|
|
57
|
+
thisIsInstance: boolean
|
|
58
|
+
): void => {
|
|
59
|
+
const { property, object } = node;
|
|
60
|
+
|
|
61
|
+
if (node.computed) {
|
|
62
|
+
if (
|
|
63
|
+
property.type === AST_NODE_TYPES.Literal &&
|
|
64
|
+
typeof property.value === 'string' &&
|
|
65
|
+
frame.candidateNames.has(property.value)
|
|
66
|
+
) {
|
|
67
|
+
frame.blocked.add(property.value);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (property.type !== AST_NODE_TYPES.Identifier || !frame.candidateNames.has(property.name)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const name = property.name;
|
|
76
|
+
|
|
77
|
+
if (node.optional) {
|
|
78
|
+
frame.blocked.add(name);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (object.type === AST_NODE_TYPES.ThisExpression) {
|
|
83
|
+
// A `this` that has rebound (inside a nested non-arrow function) isn't our
|
|
84
|
+
// member, so it's neither rewritten nor a problem — just ignored.
|
|
85
|
+
if (thisIsInstance) {
|
|
86
|
+
addReference(frame.thisRefs, name, node);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
object.type === AST_NODE_TYPES.Identifier &&
|
|
93
|
+
frame.className !== null &&
|
|
94
|
+
object.name === frame.className
|
|
95
|
+
) {
|
|
96
|
+
addReference(frame.staticRefs, name, node);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Access on some other object: without type information this could be a
|
|
101
|
+
// same-class instance (which `#` allows) or an unrelated property. Can't
|
|
102
|
+
// tell, so refuse to autofix this name.
|
|
103
|
+
frame.blocked.add(name);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Blocks any candidate name destructured off an object (`const { name } = …`),
|
|
108
|
+
* since destructuring has no `#name` form.
|
|
109
|
+
*
|
|
110
|
+
* @param frame The enclosing class's frame
|
|
111
|
+
* @param node The object pattern
|
|
112
|
+
*/
|
|
113
|
+
export const recordDestructuring = (frame: ClassFrame, node: TSESTree.ObjectPattern): void => {
|
|
114
|
+
for (const property of node.properties) {
|
|
115
|
+
if (
|
|
116
|
+
property.type === AST_NODE_TYPES.Property &&
|
|
117
|
+
property.key.type === AST_NODE_TYPES.Identifier &&
|
|
118
|
+
frame.candidateNames.has(property.key.name)
|
|
119
|
+
) {
|
|
120
|
+
frame.blocked.add(property.key.name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Appends a reference to the per-name list in `map`.
|
|
127
|
+
*
|
|
128
|
+
* @param map The reference map to add to
|
|
129
|
+
* @param name The member name
|
|
130
|
+
* @param node The member-access expression
|
|
131
|
+
*/
|
|
132
|
+
const addReference = (
|
|
133
|
+
map: Map<string, TSESTree.MemberExpression[]>,
|
|
134
|
+
name: string,
|
|
135
|
+
node: TSESTree.MemberExpression
|
|
136
|
+
): void => {
|
|
137
|
+
const existing = map.get(name);
|
|
138
|
+
if (existing) {
|
|
139
|
+
existing.push(node);
|
|
140
|
+
} else {
|
|
141
|
+
map.set(name, [node]);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
import { type ClassFrame, type FixableMember, type FixTarget, type MessageId } from './types';
|
|
3
|
+
|
|
4
|
+
/** A member to report, with the message describing why. */
|
|
5
|
+
export type MemberReport = { member: FixableMember; messageId: MessageId };
|
|
6
|
+
|
|
7
|
+
/** The outcome of analyzing a class: what to report, and what an autofix converts. */
|
|
8
|
+
export type ClassReports = { reports: MemberReport[]; targets: FixTarget[] };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turns a fully-populated frame into the per-member reports and the set of
|
|
12
|
+
* fixable conversion targets. Each name claims its references once, so a
|
|
13
|
+
* get/set pair sharing a name doesn't rewrite the same reference twice.
|
|
14
|
+
*
|
|
15
|
+
* @param frame The class frame, after traversal has recorded its references
|
|
16
|
+
*/
|
|
17
|
+
export const collectClassReports = (frame: ClassFrame): ClassReports => {
|
|
18
|
+
const reports: MemberReport[] = [];
|
|
19
|
+
const targets: FixTarget[] = [];
|
|
20
|
+
const claimedNames = new Set<string>();
|
|
21
|
+
|
|
22
|
+
for (const member of frame.classNode.body.body) {
|
|
23
|
+
if (
|
|
24
|
+
member.type !== AST_NODE_TYPES.PropertyDefinition &&
|
|
25
|
+
member.type !== AST_NODE_TYPES.MethodDefinition &&
|
|
26
|
+
member.type !== AST_NODE_TYPES.AccessorProperty
|
|
27
|
+
) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (member.accessibility !== 'private') {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (member.type === AST_NODE_TYPES.MethodDefinition && member.kind === 'constructor') {
|
|
35
|
+
reports.push({ member, messageId: 'privateConstructor' });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const messageId: MessageId =
|
|
40
|
+
member.type === AST_NODE_TYPES.MethodDefinition ? 'privateMethod' : 'privateField';
|
|
41
|
+
reports.push({ member, messageId });
|
|
42
|
+
|
|
43
|
+
const name =
|
|
44
|
+
!member.computed && member.key.type === AST_NODE_TYPES.Identifier ? member.key.name : null;
|
|
45
|
+
if (name === null || frame.blocked.has(name) || frame.existingPrivateNames.has(name)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const refs = claimedNames.has(name)
|
|
50
|
+
? []
|
|
51
|
+
: [...(frame.thisRefs.get(name) ?? []), ...(frame.staticRefs.get(name) ?? [])];
|
|
52
|
+
claimedNames.add(name);
|
|
53
|
+
targets.push({ member, refs });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { reports, targets };
|
|
57
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# `no-private-modifier`
|
|
2
|
+
|
|
3
|
+
Class members must use the ECMAScript `#private` syntax instead of the
|
|
4
|
+
TypeScript `private` accessibility modifier.
|
|
5
|
+
|
|
6
|
+
Native `#private` fields are enforced at runtime and are truly inaccessible from
|
|
7
|
+
outside the class, whereas TypeScript's `private` is a compile-time-only
|
|
8
|
+
annotation that disappears in the emitted JavaScript. Standardizing on `#`
|
|
9
|
+
removes that ambiguity.
|
|
10
|
+
|
|
11
|
+
## What it checks
|
|
12
|
+
|
|
13
|
+
Every form of the `private` modifier on a class member is reported:
|
|
14
|
+
|
|
15
|
+
- Instance and `static` fields, including `accessor` properties.
|
|
16
|
+
- Instance and `static` methods, getters, and setters.
|
|
17
|
+
- Constructors (`private constructor() {}`).
|
|
18
|
+
|
|
19
|
+
`public` and `protected` members, and members with no modifier, are left alone —
|
|
20
|
+
this rule is only concerned with replacing `private`.
|
|
21
|
+
|
|
22
|
+
**Constructor parameter properties are allowed.** `constructor(private foo: T)`
|
|
23
|
+
is a concise, readable shorthand that declares and assigns the field in one
|
|
24
|
+
place, and `#private` has no equivalent. Flagging it would force the verbose
|
|
25
|
+
field-plus-assignment form for no real benefit, so the rule permits it.
|
|
26
|
+
|
|
27
|
+
## Autofix
|
|
28
|
+
|
|
29
|
+
None. Converting `private` to `#` requires renaming every reference
|
|
30
|
+
(`this.foo` → `this.#foo`), and parameter properties additionally need a field
|
|
31
|
+
declaration plus a constructor-body assignment. These transforms are not safe to
|
|
32
|
+
apply automatically, so violations are reported without a fix.
|
|
33
|
+
|
|
34
|
+
## Rule details
|
|
35
|
+
|
|
36
|
+
Examples of **incorrect** code:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
class Counter {
|
|
40
|
+
private count = 0;
|
|
41
|
+
private static total = 0;
|
|
42
|
+
private accessor label = '';
|
|
43
|
+
|
|
44
|
+
private increment(): void {}
|
|
45
|
+
|
|
46
|
+
private constructor() {}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Examples of **correct** code:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
class Counter {
|
|
54
|
+
#count = 0;
|
|
55
|
+
static #total = 0;
|
|
56
|
+
accessor #label = '';
|
|
57
|
+
|
|
58
|
+
#increment(): void {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class Service {
|
|
62
|
+
// Parameter properties are allowed.
|
|
63
|
+
constructor(private readonly client: Client) {}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## When not to use it
|
|
68
|
+
|
|
69
|
+
Disable it if you need TypeScript `private` semantics specifically — for
|
|
70
|
+
example, when a member must remain reflectively accessible at runtime, or when
|
|
71
|
+
interoperating with code that reaches into instances in ways true `#private`
|
|
72
|
+
fields would break.
|