@ethlete/eslint-plugin 0.1.0-next.9 → 1.0.0-next.12
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/CHANGELOG.md +26 -0
- package/README.md +7 -6
- package/package.json +2 -1
- package/src/configs/recommended.js +13 -1
- package/src/index.js +6 -2
- package/src/rules/internals/angular-io.js +78 -0
- package/src/rules/no-native-html-input-name.js +94 -0
- package/src/rules/no-redundant-on-push-change-detection.js +237 -0
- package/src/rules/prefer-present-tense-output.js +92 -0
- package/src/rules/require-on-push-change-detection.js +49 -0
- package/src/rules/no-public-property.js +0 -85
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.12
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`44adcac`](https://github.com/ethlete-io/ethdk/commit/44adcac94d7e0f56742e02901221c6e04da7df47) Thanks [@TomTomB](https://github.com/TomTomB)! - Add two component I/O naming rules:
|
|
8
|
+
- `no-native-html-input-name` (error) — flags an `input()`/`model()` named after a global HTML attribute (`title`, `id`, `hidden`, `role`, `tabindex`, …), which collides with the attribute the host element carries natively.
|
|
9
|
+
- `prefer-present-tense-output` (warn) — nudges `output()` names toward the present tense like native DOM events (`playerSelect`, not `playerSelected`).
|
|
10
|
+
|
|
11
|
+
The `on`-prefix case is already covered by `@angular-eslint/no-output-on-prefix`, so no rule is added for it.
|
|
12
|
+
|
|
13
|
+
## 1.0.0-next.11
|
|
14
|
+
|
|
15
|
+
### Major Changes
|
|
16
|
+
|
|
17
|
+
- [#3016](https://github.com/ethlete-io/ethdk/pull/3016) [`396bdfb`](https://github.com/ethlete-io/ethdk/commit/396bdfb50dc51d2de0156dd7b7cc0ae3b21dfe9b) Thanks [@github-actions](https://github.com/apps/github-actions)! - Remove the `no-public-property` rule. It contradicted `template-member-accessibility`, which requires an explicit `public` modifier on surface members — the explicit-public style is the intended one. The rule was never part of the `recommended` config; if you enabled `ethlete/no-public-property` manually, drop it from your ESLint config.
|
|
18
|
+
|
|
19
|
+
## 0.1.0-next.10
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- [`4e9f2b4`](https://github.com/ethlete-io/ethdk/commit/4e9f2b4d12335fafef192350aef8ffc584996a91) Thanks [@TomTomB](https://github.com/TomTomB)! - Make the OnPush change detection rules Angular-version aware. Since OnPush is the default from Angular 22, declaring it is now redundant:
|
|
24
|
+
- Add `no-redundant-on-push-change-detection`, which flags and auto-fixes explicit `changeDetection: ChangeDetectionStrategy.OnPush` on `@Component`, and removes the now-unused `ChangeDetectionStrategy` import when it is no longer referenced. Active on Angular >= 22.
|
|
25
|
+
- `require-on-push-change-detection` now only applies on Angular <= 21, where OnPush is opt-in.
|
|
26
|
+
|
|
27
|
+
Both rules resolve the workspace's Angular major version automatically (overridable via `settings.ethlete.angularMajor`) and stay inert when they don't apply, so exactly one of them is ever active for a given Angular version.
|
|
28
|
+
|
|
3
29
|
## 0.1.0-next.9
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ ESLint plugin with custom rules and shareable flat configs for the Ethlete codin
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
yarn add --dev @ethlete/eslint-plugin
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
@@ -17,9 +17,10 @@ import ethlete from '@ethlete/eslint-plugin';
|
|
|
17
17
|
export default [...ethlete.configs.recommended];
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## Documentation
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
The full rule reference (all rules, grouped, with defaults and auto-fix support) and
|
|
23
|
+
config documentation live on the docs site:
|
|
24
|
+
|
|
25
|
+
- [Overview & usage](https://ethlete-sdk-docs.web.app/eslint/)
|
|
26
|
+
- [Rule reference](https://ethlete-sdk-docs.web.app/eslint/rules)
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/eslint-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0-next.12",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"peerDependencies": {
|
|
8
8
|
"@angular-eslint/eslint-plugin-template": ">=21.0.0",
|
|
9
|
+
"@angular/core": "22.0.5",
|
|
9
10
|
"@typescript-eslint/eslint-plugin": ">=8.0.0",
|
|
10
11
|
"eslint": ">=9.0.0",
|
|
11
12
|
"tslib": "2.8.1",
|
|
@@ -350,9 +350,14 @@ const recommendedTs = {
|
|
|
350
350
|
|
|
351
351
|
// ── Angular components ──────────────────────────────────────────────────
|
|
352
352
|
|
|
353
|
-
//
|
|
353
|
+
// Require ChangeDetectionStrategy.OnPush on Angular <= 21, where it is opt-in
|
|
354
|
+
// (version-aware: inert on Angular 22+, where OnPush is the default).
|
|
354
355
|
'ethlete/require-on-push-change-detection': 'error',
|
|
355
356
|
|
|
357
|
+
// No redundant ChangeDetectionStrategy.OnPush — it is the default since Angular 22
|
|
358
|
+
// (version-aware: inert on older Angular). Also removes the now-unused import.
|
|
359
|
+
'ethlete/no-redundant-on-push-change-detection': 'error',
|
|
360
|
+
|
|
356
361
|
// Always use ViewEncapsulation.None (angular-eslint's use-component-view-encapsulation does the
|
|
357
362
|
// opposite — it disallows None, so we use our own custom rule instead)
|
|
358
363
|
'ethlete/require-view-encapsulation-none': 'error',
|
|
@@ -377,6 +382,13 @@ const recommendedTs = {
|
|
|
377
382
|
|
|
378
383
|
// Routing components must use paths containing "-view" and class names ending in "ViewComponent"
|
|
379
384
|
'ethlete/enforce-routing-view-naming': 'error',
|
|
385
|
+
|
|
386
|
+
// Inputs/models must not be named after a global HTML attribute (title, id, hidden, …) — it collides with the host element
|
|
387
|
+
'ethlete/no-native-html-input-name': 'error',
|
|
388
|
+
|
|
389
|
+
// Outputs should read in the present tense like native DOM events: (playerSelect), not (playerSelected)
|
|
390
|
+
// (The `on` prefix is already covered by @angular-eslint/no-output-on-prefix.)
|
|
391
|
+
'ethlete/prefer-present-tense-output': 'warn',
|
|
380
392
|
},
|
|
381
393
|
};
|
|
382
394
|
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,7 @@ const noSubscribeWithBody = require('./rules/no-subscribe-with-body');
|
|
|
22
22
|
const requireDollarSuffix = require('./rules/require-dollar-suffix');
|
|
23
23
|
const noPipeLogic = require('./rules/no-pipe-logic');
|
|
24
24
|
const enforceRoutingViewNaming = require('./rules/enforce-routing-view-naming');
|
|
25
|
+
const noRedundantOnPushChangeDetection = require('./rules/no-redundant-on-push-change-detection');
|
|
25
26
|
const requireOnPushChangeDetection = require('./rules/require-on-push-change-detection');
|
|
26
27
|
const requireViewEncapsulationNone = require('./rules/require-view-encapsulation-none');
|
|
27
28
|
const noLegacyAngularDecorators = require('./rules/no-legacy-angular-decorators');
|
|
@@ -29,7 +30,6 @@ const noAngularSeoServices = require('./rules/no-angular-seo-services');
|
|
|
29
30
|
const preferCloneEqual = require('./rules/prefer-clone-equal');
|
|
30
31
|
const noDocumentCookie = require('./rules/no-document-cookie');
|
|
31
32
|
const injectMemberAccessibility = require('./rules/inject-member-accessibility');
|
|
32
|
-
const noPublicProperty = require('./rules/no-public-property');
|
|
33
33
|
const noRedundantInternal = require('./rules/no-redundant-internal');
|
|
34
34
|
const templateMemberAccessibility = require('./rules/template-member-accessibility');
|
|
35
35
|
const noAngularRouterApi = require('./rules/no-angular-router-api');
|
|
@@ -48,6 +48,8 @@ const classConstantProperty = require('./rules/class-constant-property');
|
|
|
48
48
|
const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
|
|
49
49
|
const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
|
|
50
50
|
const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref');
|
|
51
|
+
const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
|
|
52
|
+
const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
|
|
51
53
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
52
54
|
|
|
53
55
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -78,6 +80,7 @@ const plugin = {
|
|
|
78
80
|
'require-dollar-suffix': requireDollarSuffix,
|
|
79
81
|
'no-pipe-logic': noPipeLogic,
|
|
80
82
|
'enforce-routing-view-naming': enforceRoutingViewNaming,
|
|
83
|
+
'no-redundant-on-push-change-detection': noRedundantOnPushChangeDetection,
|
|
81
84
|
'require-on-push-change-detection': requireOnPushChangeDetection,
|
|
82
85
|
'require-view-encapsulation-none': requireViewEncapsulationNone,
|
|
83
86
|
'no-legacy-angular-decorators': noLegacyAngularDecorators,
|
|
@@ -85,7 +88,6 @@ const plugin = {
|
|
|
85
88
|
'prefer-clone-equal': preferCloneEqual,
|
|
86
89
|
'no-document-cookie': noDocumentCookie,
|
|
87
90
|
'inject-member-accessibility': injectMemberAccessibility,
|
|
88
|
-
'no-public-property': noPublicProperty,
|
|
89
91
|
'no-redundant-internal': noRedundantInternal,
|
|
90
92
|
'template-member-accessibility': templateMemberAccessibility,
|
|
91
93
|
'no-angular-router-api': noAngularRouterApi,
|
|
@@ -104,6 +106,8 @@ const plugin = {
|
|
|
104
106
|
'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
|
|
105
107
|
'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
|
|
106
108
|
'no-typed-injected-element-ref': noTypedInjectedElementRef,
|
|
109
|
+
'no-native-html-input-name': noNativeHtmlInputName,
|
|
110
|
+
'prefer-present-tense-output': preferPresentTenseOutput,
|
|
107
111
|
},
|
|
108
112
|
};
|
|
109
113
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared detection for Angular signal-based inputs and outputs.
|
|
6
|
+
*
|
|
7
|
+
* This repo declares component I/O exclusively via the signal factory functions
|
|
8
|
+
* (no `@Input()` / `@Output()` decorators):
|
|
9
|
+
*
|
|
10
|
+
* foo = input(...) → input
|
|
11
|
+
* foo = input.required<T>(...) → input
|
|
12
|
+
* foo = model(...) → model (two-way input)
|
|
13
|
+
* foo = model.required<T>(...) → model
|
|
14
|
+
* foo = output<T>() → output
|
|
15
|
+
* foo = outputFromObservable(...) → output
|
|
16
|
+
*
|
|
17
|
+
* `getReactiveIo` maps a class `PropertyDefinition` node onto one of those kinds
|
|
18
|
+
* (or null when the property is not a reactive I/O declaration).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Factory identifier → I/O kind. */
|
|
22
|
+
const IO_FACTORIES = {
|
|
23
|
+
input: 'input',
|
|
24
|
+
model: 'model',
|
|
25
|
+
output: 'output',
|
|
26
|
+
outputFromObservable: 'output',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the factory function name of a call expression, handling both the
|
|
31
|
+
* plain form (`input(...)`) and the `.required` member form
|
|
32
|
+
* (`input.required(...)`).
|
|
33
|
+
*
|
|
34
|
+
* @param {any} callee
|
|
35
|
+
* @returns {string | null}
|
|
36
|
+
*/
|
|
37
|
+
const getFactoryName = (callee) => {
|
|
38
|
+
if (!callee) return null;
|
|
39
|
+
|
|
40
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
41
|
+
|
|
42
|
+
// input.required(...) / model.required(...)
|
|
43
|
+
if (
|
|
44
|
+
callee.type === 'MemberExpression' &&
|
|
45
|
+
!callee.computed &&
|
|
46
|
+
callee.object.type === 'Identifier' &&
|
|
47
|
+
callee.property.type === 'Identifier' &&
|
|
48
|
+
callee.property.name === 'required'
|
|
49
|
+
) {
|
|
50
|
+
return callee.object.name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Classifies a class property as an Angular signal input/model/output.
|
|
58
|
+
*
|
|
59
|
+
* @param {any} node PropertyDefinition
|
|
60
|
+
* @returns {{ kind: 'input' | 'model' | 'output', name: string, keyNode: any } | null}
|
|
61
|
+
*/
|
|
62
|
+
const getReactiveIo = (node) => {
|
|
63
|
+
if (!node || node.type !== 'PropertyDefinition') return null;
|
|
64
|
+
if (node.computed || node.key.type !== 'Identifier') return null;
|
|
65
|
+
|
|
66
|
+
const value = node.value;
|
|
67
|
+
if (!value || value.type !== 'CallExpression') return null;
|
|
68
|
+
|
|
69
|
+
const factory = getFactoryName(value.callee);
|
|
70
|
+
if (!factory) return null;
|
|
71
|
+
|
|
72
|
+
const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
|
|
73
|
+
if (!kind) return null;
|
|
74
|
+
|
|
75
|
+
return { kind, name: node.key.name, keyNode: node.key };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
module.exports = { getReactiveIo };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getReactiveIo } = require('./internals/angular-io');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Disallows naming an `input()` / `model()` after a global HTML attribute.
|
|
8
|
+
*
|
|
9
|
+
* A component's host element carries every global attribute natively, so an
|
|
10
|
+
* input named `title`, `id`, `hidden`, `role`, … collides with the real DOM
|
|
11
|
+
* attribute: the browser applies its native behaviour (a `title` tooltip, a
|
|
12
|
+
* duplicated `id`, native `hidden`/`role` semantics) in addition to — or
|
|
13
|
+
* instead of — the component's intent. Rename the input (e.g. `label`,
|
|
14
|
+
* `overlayId`, `isHidden`) so it can't clash.
|
|
15
|
+
*
|
|
16
|
+
* Only the *global* attributes (those valid on any element) are flagged.
|
|
17
|
+
* Element-specific attributes that libraries routinely mirror on purpose
|
|
18
|
+
* (`disabled`, `value`, `placeholder`, `type`, `size`, `min`, `max`, …) are
|
|
19
|
+
* intentionally NOT flagged — they don't apply to an arbitrary host element and
|
|
20
|
+
* mirroring them is a deliberate, well-understood pattern.
|
|
21
|
+
*
|
|
22
|
+
* ❌ title = input('...')
|
|
23
|
+
* ❌ id = input(...)
|
|
24
|
+
* ❌ hidden = input(false)
|
|
25
|
+
* ✅ label = input('...')
|
|
26
|
+
* ✅ disabled = input(false) // element-specific, intentional mirror
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Global HTML attributes (valid on every element) + `role`, which behaves the
|
|
30
|
+
// same way. Element-specific attributes are deliberately excluded — see above.
|
|
31
|
+
//
|
|
32
|
+
// Microdata (`itemid`, `itemprop`, …) and other niche globals (`is`, `nonce`,
|
|
33
|
+
// `part`, `exportparts`, …) are also excluded: they are rarely used, so an input
|
|
34
|
+
// named after one is almost never a real collision, whereas the names double as
|
|
35
|
+
// common domain terms (e.g. `itemId` on a list item). Keeping them in would
|
|
36
|
+
// produce more false positives than caught footguns.
|
|
37
|
+
const GLOBAL_HTML_ATTRIBUTES = new Set([
|
|
38
|
+
'accesskey',
|
|
39
|
+
'autocapitalize',
|
|
40
|
+
'autofocus',
|
|
41
|
+
'class',
|
|
42
|
+
'contenteditable',
|
|
43
|
+
'dir',
|
|
44
|
+
'draggable',
|
|
45
|
+
'enterkeyhint',
|
|
46
|
+
'hidden',
|
|
47
|
+
'id',
|
|
48
|
+
'inert',
|
|
49
|
+
'inputmode',
|
|
50
|
+
'lang',
|
|
51
|
+
'popover',
|
|
52
|
+
'role',
|
|
53
|
+
'slot',
|
|
54
|
+
'spellcheck',
|
|
55
|
+
'style',
|
|
56
|
+
'tabindex',
|
|
57
|
+
'title',
|
|
58
|
+
'translate',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
62
|
+
const rule = {
|
|
63
|
+
meta: {
|
|
64
|
+
type: 'problem',
|
|
65
|
+
docs: {
|
|
66
|
+
description: 'Disallow naming an input/model after a global HTML attribute (it collides with the host element).',
|
|
67
|
+
recommended: true,
|
|
68
|
+
},
|
|
69
|
+
schema: [],
|
|
70
|
+
messages: {
|
|
71
|
+
nativeName:
|
|
72
|
+
"Input '{{ name }}' shares its name with the global HTML attribute '{{ name }}', which every host element carries natively. Rename it to avoid the clash.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
create(context) {
|
|
76
|
+
return {
|
|
77
|
+
PropertyDefinition(node) {
|
|
78
|
+
const io = getReactiveIo(node);
|
|
79
|
+
if (!io) return;
|
|
80
|
+
if (io.kind === 'output') return;
|
|
81
|
+
|
|
82
|
+
if (!GLOBAL_HTML_ATTRIBUTES.has(io.name.toLowerCase())) return;
|
|
83
|
+
|
|
84
|
+
context.report({
|
|
85
|
+
node: io.keyNode,
|
|
86
|
+
messageId: 'nativeName',
|
|
87
|
+
data: { name: io.name },
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
module.exports = rule;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallow explicit `changeDetection: ChangeDetectionStrategy.OnPush` on
|
|
6
|
+
* `@Component` decorators. Since Angular 22, OnPush is the default change
|
|
7
|
+
* detection strategy, so declaring it is redundant noise.
|
|
8
|
+
*
|
|
9
|
+
* The rule is version-aware: it only activates when the workspace's installed
|
|
10
|
+
* Angular is v22 or newer (or when the version cannot be determined). On older
|
|
11
|
+
* Angular versions — where OnPush is NOT the default — it stays silent so that
|
|
12
|
+
* removing it would not change behaviour.
|
|
13
|
+
*
|
|
14
|
+
* BAD (Angular >= 22):
|
|
15
|
+
* @Component({ selector: 'et-x', template: '', changeDetection: ChangeDetectionStrategy.OnPush })
|
|
16
|
+
*
|
|
17
|
+
* GOOD (Angular >= 22):
|
|
18
|
+
* @Component({ selector: 'et-x', template: '' })
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REQUIRED_MAJOR = 22;
|
|
22
|
+
|
|
23
|
+
/** @type {number | null | undefined} */
|
|
24
|
+
let cachedAngularMajor;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Detect the workspace's installed Angular major version. Cached across files.
|
|
28
|
+
*
|
|
29
|
+
* @returns {number | null} major version, or null when it cannot be determined
|
|
30
|
+
*/
|
|
31
|
+
const detectAngularMajor = () => {
|
|
32
|
+
if (cachedAngularMajor !== undefined) return cachedAngularMajor;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const pkg = require('@angular/core/package.json');
|
|
36
|
+
const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
|
|
37
|
+
cachedAngularMajor = Number.isNaN(major) ? null : major;
|
|
38
|
+
} catch {
|
|
39
|
+
cachedAngularMajor = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return cachedAngularMajor;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the Angular major version to gate on. An explicit
|
|
47
|
+
* `settings.ethlete.angularMajor` wins over auto-detection — handy for
|
|
48
|
+
* pinning behaviour and for tests.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
51
|
+
* @returns {number | null}
|
|
52
|
+
*/
|
|
53
|
+
const getAngularMajor = (context) => {
|
|
54
|
+
const settings = /** @type {any} */ (context.settings);
|
|
55
|
+
const override = settings?.ethlete?.angularMajor;
|
|
56
|
+
if (typeof override === 'number') return override;
|
|
57
|
+
|
|
58
|
+
return detectAngularMajor();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {import('estree').Property['key']} key
|
|
63
|
+
*/
|
|
64
|
+
const getPropertyName = (key) => {
|
|
65
|
+
if (key.type === 'Identifier') return key.name;
|
|
66
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('eslint').Rule.Node} node
|
|
72
|
+
*/
|
|
73
|
+
const isComponentDecorator = (node) => {
|
|
74
|
+
const decorator = /** @type {any} */ (node);
|
|
75
|
+
if (decorator.type !== 'Decorator') return false;
|
|
76
|
+
|
|
77
|
+
const expression = decorator.expression;
|
|
78
|
+
if (expression.type === 'CallExpression') {
|
|
79
|
+
return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return expression.type === 'Identifier' && expression.name === 'Component';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {any} value
|
|
87
|
+
*/
|
|
88
|
+
const isOnPushValue = (value) =>
|
|
89
|
+
value.type === 'MemberExpression' &&
|
|
90
|
+
value.object.type === 'Identifier' &&
|
|
91
|
+
value.object.name === 'ChangeDetectionStrategy' &&
|
|
92
|
+
value.property.type === 'Identifier' &&
|
|
93
|
+
value.property.name === 'OnPush';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Range that removes a property together with the comma that joins it to its
|
|
97
|
+
* siblings (leading comma when it is the last property, otherwise trailing).
|
|
98
|
+
*
|
|
99
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
100
|
+
* @param {any} metadata
|
|
101
|
+
* @param {any} property
|
|
102
|
+
*/
|
|
103
|
+
const getPropertyRemovalRange = (sourceCode, metadata, property) => {
|
|
104
|
+
const properties = metadata.properties.filter((entry) => entry.type === 'Property');
|
|
105
|
+
const index = properties.indexOf(property);
|
|
106
|
+
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
107
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
108
|
+
|
|
109
|
+
if (!openingBrace || !closingBrace) return property.range;
|
|
110
|
+
if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
111
|
+
if (index < properties.length - 1) return [property.range[0], properties[index + 1].range[0]];
|
|
112
|
+
|
|
113
|
+
return [properties[index - 1].range[1], property.range[1]];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Range that removes a single named import specifier together with its comma.
|
|
118
|
+
*
|
|
119
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
120
|
+
* @param {any} importNode
|
|
121
|
+
* @param {any} specifier
|
|
122
|
+
*/
|
|
123
|
+
const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
|
|
124
|
+
const named = importNode.specifiers.filter((entry) => entry.type === 'ImportSpecifier');
|
|
125
|
+
const index = named.indexOf(specifier);
|
|
126
|
+
|
|
127
|
+
// Sole specifier of the whole import — drop the entire declaration.
|
|
128
|
+
if (importNode.specifiers.length === 1) return importNode.range;
|
|
129
|
+
if (index < named.length - 1) return [specifier.range[0], named[index + 1].range[0]];
|
|
130
|
+
|
|
131
|
+
return [named[index - 1].range[1], specifier.range[1]];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* True when every reference of the `ChangeDetectionStrategy` binding is a
|
|
136
|
+
* `changeDetection: ChangeDetectionStrategy.OnPush` property value — i.e.
|
|
137
|
+
* removing those properties leaves the import unused and safe to drop.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('eslint').Scope.Reference} reference
|
|
140
|
+
*/
|
|
141
|
+
const isOnPushChangeDetectionReference = (reference) => {
|
|
142
|
+
const identifier = /** @type {any} */ (reference.identifier);
|
|
143
|
+
const member = identifier.parent;
|
|
144
|
+
if (!member || member.type !== 'MemberExpression' || member.object !== identifier) return false;
|
|
145
|
+
if (member.property.type !== 'Identifier' || member.property.name !== 'OnPush') return false;
|
|
146
|
+
|
|
147
|
+
const property = member.parent;
|
|
148
|
+
if (!property || property.type !== 'Property' || property.value !== member) return false;
|
|
149
|
+
|
|
150
|
+
return getPropertyName(property.key) === 'changeDetection';
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
154
|
+
const noRedundantOnPushChangeDetection = {
|
|
155
|
+
meta: {
|
|
156
|
+
type: 'suggestion',
|
|
157
|
+
docs: {
|
|
158
|
+
description:
|
|
159
|
+
'Disallow redundant `changeDetection: ChangeDetectionStrategy.OnPush`. OnPush is the default since Angular 22.',
|
|
160
|
+
},
|
|
161
|
+
fixable: 'code',
|
|
162
|
+
schema: [],
|
|
163
|
+
messages: {
|
|
164
|
+
redundant:
|
|
165
|
+
'`changeDetection: ChangeDetectionStrategy.OnPush` is redundant. OnPush is the default change detection strategy since Angular 22.',
|
|
166
|
+
redundantImport:
|
|
167
|
+
'Remove the now-unused `ChangeDetectionStrategy` import. OnPush is the default change detection strategy since Angular 22.',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
create(context) {
|
|
171
|
+
// Only enforce on Angular >= 22, where OnPush is the default. Stay silent on
|
|
172
|
+
// older versions (removing it there would change behaviour) — but enforce
|
|
173
|
+
// when the version is unknown, since this workspace targets modern Angular.
|
|
174
|
+
const angularMajor = getAngularMajor(context);
|
|
175
|
+
if (angularMajor !== null && angularMajor < REQUIRED_MAJOR) return {};
|
|
176
|
+
|
|
177
|
+
const sourceCode = context.sourceCode;
|
|
178
|
+
/** @type {any} */
|
|
179
|
+
let changeDetectionSpecifier = null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
Decorator(node) {
|
|
183
|
+
if (!isComponentDecorator(node)) return;
|
|
184
|
+
|
|
185
|
+
const expression = /** @type {any} */ (node).expression;
|
|
186
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
187
|
+
|
|
188
|
+
const metadata = expression.arguments[0];
|
|
189
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
190
|
+
|
|
191
|
+
const property = metadata.properties.find(
|
|
192
|
+
(entry) => entry.type === 'Property' && getPropertyName(entry.key) === 'changeDetection',
|
|
193
|
+
);
|
|
194
|
+
if (!property || !isOnPushValue(property.value)) return;
|
|
195
|
+
|
|
196
|
+
context.report({
|
|
197
|
+
node: property,
|
|
198
|
+
messageId: 'redundant',
|
|
199
|
+
fix: (fixer) => fixer.replaceTextRange(getPropertyRemovalRange(sourceCode, metadata, property), ''),
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
ImportDeclaration(node) {
|
|
204
|
+
if (node.source.value !== '@angular/core') return;
|
|
205
|
+
|
|
206
|
+
for (const specifier of node.specifiers) {
|
|
207
|
+
if (
|
|
208
|
+
specifier.type === 'ImportSpecifier' &&
|
|
209
|
+
specifier.imported.type === 'Identifier' &&
|
|
210
|
+
specifier.imported.name === 'ChangeDetectionStrategy'
|
|
211
|
+
) {
|
|
212
|
+
changeDetectionSpecifier = specifier;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
'Program:exit'() {
|
|
219
|
+
if (!changeDetectionSpecifier) return;
|
|
220
|
+
|
|
221
|
+
const [variable] = sourceCode.getDeclaredVariables(changeDetectionSpecifier);
|
|
222
|
+
if (!variable || variable.references.length === 0) return;
|
|
223
|
+
if (!variable.references.every(isOnPushChangeDetectionReference)) return;
|
|
224
|
+
|
|
225
|
+
const importNode = changeDetectionSpecifier.parent;
|
|
226
|
+
context.report({
|
|
227
|
+
node: changeDetectionSpecifier,
|
|
228
|
+
messageId: 'redundantImport',
|
|
229
|
+
fix: (fixer) =>
|
|
230
|
+
fixer.replaceTextRange(getSpecifierRemovalRange(sourceCode, importNode, changeDetectionSpecifier), ''),
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
module.exports = noRedundantOnPushChangeDetection;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getReactiveIo } = require('./internals/angular-io');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nudges `output()` names toward the present tense, matching native DOM events.
|
|
8
|
+
*
|
|
9
|
+
* Native events are named in the present tense after the thing that happens:
|
|
10
|
+
* `input`, `change`, `select`, `mousedown`, `dragstart`. Components should read
|
|
11
|
+
* the same way in a template — `(playerSelect)`, not `(playerSelected)`. A
|
|
12
|
+
* past-tense name describes a state after the fact rather than the event.
|
|
13
|
+
*
|
|
14
|
+
* ❌ playerSelected = output<...>()
|
|
15
|
+
* ❌ filesRejected = output<...>()
|
|
16
|
+
* ✅ playerSelect = output<...>()
|
|
17
|
+
* ✅ filesReject = output<...>()
|
|
18
|
+
*
|
|
19
|
+
* Detection is heuristic: the final camel-case segment is flagged when it ends
|
|
20
|
+
* in `-ed` (the dominant past-participle pattern), excluding an allowlist of
|
|
21
|
+
* base-form words that merely happen to end that way (`succeed`, `feed`,
|
|
22
|
+
* `speed`, …). Irregular past tenses that don't end in `-ed` (`sent`, `shown`,
|
|
23
|
+
* `built`, …) are NOT detected — this rule catches the common case, not every
|
|
24
|
+
* one. No auto-fix: de-conjugating a verb reliably (and renaming the matching
|
|
25
|
+
* template bindings) is out of scope, so the corrected name is left to the
|
|
26
|
+
* author.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Base-form / noun words ending in "-ed" that are not past participles.
|
|
30
|
+
const NON_PAST_ED_WORDS = new Set([
|
|
31
|
+
'succeed',
|
|
32
|
+
'proceed',
|
|
33
|
+
'exceed',
|
|
34
|
+
'feed',
|
|
35
|
+
'need',
|
|
36
|
+
'breed',
|
|
37
|
+
'bleed',
|
|
38
|
+
'speed',
|
|
39
|
+
'seed',
|
|
40
|
+
'weed',
|
|
41
|
+
'embed',
|
|
42
|
+
'shed',
|
|
43
|
+
'wed',
|
|
44
|
+
'indeed',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the final camel-case segment of an identifier, lower-cased.
|
|
49
|
+
* `filesRejected` → `rejected`, `removed` → `removed`.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} name
|
|
52
|
+
*/
|
|
53
|
+
const lastWord = (name) => {
|
|
54
|
+
const segments = name.split(/(?=[A-Z])/);
|
|
55
|
+
return segments[segments.length - 1].toLowerCase();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
59
|
+
const rule = {
|
|
60
|
+
meta: {
|
|
61
|
+
type: 'suggestion',
|
|
62
|
+
docs: {
|
|
63
|
+
description: 'Prefer present-tense output names (like native DOM events) over past-tense ones.',
|
|
64
|
+
recommended: true,
|
|
65
|
+
},
|
|
66
|
+
schema: [],
|
|
67
|
+
messages: {
|
|
68
|
+
pastTense:
|
|
69
|
+
"Output '{{ name }}' looks past tense ('{{ word }}'). Name outputs after the present-tense event like native DOM events (e.g. 'change', 'select'), not their past participle ('changed', 'selected').",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
create(context) {
|
|
73
|
+
return {
|
|
74
|
+
PropertyDefinition(node) {
|
|
75
|
+
const io = getReactiveIo(node);
|
|
76
|
+
if (!io || io.kind !== 'output') return;
|
|
77
|
+
|
|
78
|
+
const word = lastWord(io.name);
|
|
79
|
+
if (word.length <= 2 || !word.endsWith('ed')) return;
|
|
80
|
+
if (NON_PAST_ED_WORDS.has(word)) return;
|
|
81
|
+
|
|
82
|
+
context.report({
|
|
83
|
+
node: io.keyNode,
|
|
84
|
+
messageId: 'pastTense',
|
|
85
|
+
data: { name: io.name, word },
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = rule;
|
|
@@ -3,6 +3,49 @@
|
|
|
3
3
|
|
|
4
4
|
const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
|
|
5
5
|
|
|
6
|
+
// OnPush became the default change detection strategy in Angular 22, so from
|
|
7
|
+
// v22 onward it must NOT be declared (see no-redundant-on-push-change-detection).
|
|
8
|
+
// This rule therefore only applies to older Angular, where OnPush is opt-in.
|
|
9
|
+
const MAX_MAJOR = 21;
|
|
10
|
+
|
|
11
|
+
/** @type {number | null | undefined} */
|
|
12
|
+
let cachedAngularMajor;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect the workspace's installed Angular major version. Cached across files.
|
|
16
|
+
*
|
|
17
|
+
* @returns {number | null} major version, or null when it cannot be determined
|
|
18
|
+
*/
|
|
19
|
+
const detectAngularMajor = () => {
|
|
20
|
+
if (cachedAngularMajor !== undefined) return cachedAngularMajor;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const pkg = require('@angular/core/package.json');
|
|
24
|
+
const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
|
|
25
|
+
cachedAngularMajor = Number.isNaN(major) ? null : major;
|
|
26
|
+
} catch {
|
|
27
|
+
cachedAngularMajor = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return cachedAngularMajor;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the Angular major version to gate on. An explicit
|
|
35
|
+
* `settings.ethlete.angularMajor` wins over auto-detection — handy for
|
|
36
|
+
* pinning behaviour and for tests.
|
|
37
|
+
*
|
|
38
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
39
|
+
* @returns {number | null}
|
|
40
|
+
*/
|
|
41
|
+
const getAngularMajor = (context) => {
|
|
42
|
+
const settings = /** @type {any} */ (context.settings);
|
|
43
|
+
const override = settings?.ethlete?.angularMajor;
|
|
44
|
+
if (typeof override === 'number') return override;
|
|
45
|
+
|
|
46
|
+
return detectAngularMajor();
|
|
47
|
+
};
|
|
48
|
+
|
|
6
49
|
/**
|
|
7
50
|
* @param {import('estree').Property['key']} key
|
|
8
51
|
*/
|
|
@@ -204,6 +247,12 @@ const requireOnPushChangeDetection = {
|
|
|
204
247
|
schema: [],
|
|
205
248
|
},
|
|
206
249
|
create(context) {
|
|
250
|
+
// Only enforce on Angular <= 21, where OnPush is opt-in. On v22+ (or when
|
|
251
|
+
// the version is unknown — this workspace targets modern Angular) stay
|
|
252
|
+
// silent so no-redundant-on-push-change-detection owns the metadata.
|
|
253
|
+
const angularMajor = getAngularMajor(context);
|
|
254
|
+
if (angularMajor === null || angularMajor > MAX_MAJOR) return {};
|
|
255
|
+
|
|
207
256
|
const sourceCode = context.sourceCode;
|
|
208
257
|
|
|
209
258
|
/**
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @param {any} node
|
|
8
|
-
*/
|
|
9
|
-
const isInjectCall = (node) => {
|
|
10
|
-
if (!node || node.type !== 'CallExpression') return false;
|
|
11
|
-
return node.callee.type === 'Identifier' && node.callee.name === 'inject';
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/** @type {import('eslint').Rule.RuleModule} */
|
|
15
|
-
const noPublicProperty = {
|
|
16
|
-
meta: {
|
|
17
|
-
type: 'problem',
|
|
18
|
-
docs: {
|
|
19
|
-
description:
|
|
20
|
-
'Disallow explicit public on class properties, except for injected members that intentionally expose an external API.',
|
|
21
|
-
recommended: true,
|
|
22
|
-
},
|
|
23
|
-
fixable: 'code',
|
|
24
|
-
schema: [],
|
|
25
|
-
messages: {
|
|
26
|
-
noPublicProperty:
|
|
27
|
-
'No public keyword on class properties. The only exception is an injected member that intentionally exposes an external API.',
|
|
28
|
-
shouldBeProtected:
|
|
29
|
-
'Member `{{name}}` should be protected instead of public because it is referenced from a template or host binding.',
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
create(context) {
|
|
33
|
-
/**
|
|
34
|
-
* @param {any} node
|
|
35
|
-
*/
|
|
36
|
-
const removePublicFix = (node) => {
|
|
37
|
-
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
38
|
-
const nextToken = publicToken ? context.sourceCode.getTokenAfter(publicToken) : null;
|
|
39
|
-
if (!publicToken || !nextToken) return null;
|
|
40
|
-
|
|
41
|
-
return (fixer) => fixer.removeRange([publicToken.range[0], nextToken.range[0]]);
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* @param {any} node
|
|
46
|
-
*/
|
|
47
|
-
const replacePublicWithProtectedFix = (node) => {
|
|
48
|
-
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
49
|
-
if (!publicToken) return null;
|
|
50
|
-
|
|
51
|
-
return (fixer) => fixer.replaceText(publicToken, 'protected');
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
PropertyDefinition(node) {
|
|
56
|
-
if (node.accessibility !== 'public') return;
|
|
57
|
-
if (isInjectCall(node.value)) return;
|
|
58
|
-
|
|
59
|
-
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
60
|
-
const memberName = getMemberName(node);
|
|
61
|
-
const isTemplateVisible =
|
|
62
|
-
classNode && memberName ? isReferencedFromTemplateOrHost(classNode, memberName, context) : false;
|
|
63
|
-
|
|
64
|
-
if (isTemplateVisible) {
|
|
65
|
-
context.report({
|
|
66
|
-
node,
|
|
67
|
-
messageId: 'shouldBeProtected',
|
|
68
|
-
data: { name: memberName },
|
|
69
|
-
fix: replacePublicWithProtectedFix(node),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
context.report({
|
|
76
|
-
node,
|
|
77
|
-
messageId: 'noPublicProperty',
|
|
78
|
-
fix: removePublicFix(node),
|
|
79
|
-
});
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
module.exports = noPublicProperty;
|