@ethlete/eslint-plugin 1.0.0-next.15 → 1.0.0-next.17
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 +30 -5
- package/package.json +2 -2
- package/src/configs/recommended.js +11 -2
- package/src/index.js +6 -0
- package/src/rules/no-effect-cleanup-return.js +179 -0
- package/src/rules/no-impure-top-level-provider.js +129 -0
- package/src/rules/no-template-literal-before-inline-template.js +190 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.17
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3041](https://github.com/ethlete-io/ethdk/pull/3041) [`ae18442`](https://github.com/ethlete-io/ethdk/commit/ae18442bcbeaf0bc1b023812f4e507169db56c88) Thanks [@github-actions](https://github.com/apps/github-actions)! - New rule `no-impure-top-level-provider`: bans destructuring a factory call at module scope, and - with
|
|
8
|
+
`{ requirePureAnnotation: true }`, for publishable library source - requires `/* @__PURE__ */` on
|
|
9
|
+
module-scope calls. Both shapes are undroppable, so one of them puts everything the call touches into
|
|
10
|
+
every consumer's bundle. Fixable.
|
|
11
|
+
|
|
12
|
+
## 1.0.0-next.16
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- [#3037](https://github.com/ethlete-io/ethdk/pull/3037) [`19469c2`](https://github.com/ethlete-io/ethdk/commit/19469c21903217e05b974ac69773f222e63ae4e1) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `no-effect-cleanup-return`: flags a cleanup function returned from `effect()` /
|
|
17
|
+
`afterRenderEffect()`, which Angular ignores - so the teardown silently never runs. Auto-fixes the
|
|
18
|
+
mechanical case to the `onCleanup` parameter; otherwise points at `inject(DestroyRef).onDestroy()`.
|
|
19
|
+
|
|
20
|
+
- [#3037](https://github.com/ethlete-io/ethdk/pull/3037) [`3151b7a`](https://github.com/ethlete-io/ethdk/commit/3151b7a253d14e38e22e20d67bf0191f141c144e) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `ethlete/no-template-literal-before-inline-template`, and restructure the files it flagged.
|
|
21
|
+
|
|
22
|
+
The Angular VS Code extension decides **client-side** whether the cursor sits inside an inline `template:` before it forwards completion, hover, go-to-definition or signature-help to the language server. That check (`isNotTypescriptOrSupportedDecoratorField`) walks the file with a bare `ts.createScanner()` loop, which cannot re-scan `}` as `TemplateMiddle`/`TemplateTail` - that needs the parser's `reScanTemplateToken()`. So the first template literal containing a `${…}` substitution desynchronises both the token stream and the brace counter, the scanner never recognises `template` `:` again, and every template request below it is dropped. The language server answers those requests correctly; the editor just never asks, so the template silently has no IntelliSense at all.
|
|
23
|
+
|
|
24
|
+
The new rule reproduces that scanner verbatim, so it reports exactly the templates the extension would abandon - no heuristic. Twenty inline templates across `components`, `cdk` and the playground were affected, all of them behind a fixture or helper that happened to use an interpolated template literal. Story fixtures moved into sibling `*-storybook.data.ts` files; spec fixtures and in-class helpers that must stay above their component (because a later `@Component` references the class in `imports`) were rewritten without the interpolation.
|
|
25
|
+
|
|
26
|
+
No public API changed - the `components` and `cdk` bumps are story/spec restructuring plus moving `signalVisibilityChangeClasses` below `RichFilterHostComponent` in the same module.
|
|
27
|
+
|
|
3
28
|
## 1.0.0-next.15
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
|
@@ -12,13 +37,13 @@
|
|
|
12
37
|
|
|
13
38
|
- [`423b30b`](https://github.com/ethlete-io/ethdk/commit/423b30ba4f368d7cee8c464ebd89b5df20d3934c) Thanks [@TomTomB](https://github.com/TomTomB)! - The `static` class member ban now excepts `ngTemplateContextGuard`, which Angular's template type checker requires to be static.
|
|
14
39
|
|
|
15
|
-
- [`7ca77d4`](https://github.com/ethlete-io/ethdk/commit/7ca77d4a9e1dd3abad7237227c62643159719b74) Thanks [@TomTomB](https://github.com/TomTomB)! - `no-trivial-return-type`: self-referencing (recursive) functions keep their return type annotation
|
|
40
|
+
- [`7ca77d4`](https://github.com/ethlete-io/ethdk/commit/7ca77d4a9e1dd3abad7237227c62643159719b74) Thanks [@TomTomB](https://github.com/TomTomB)! - `no-trivial-return-type`: self-referencing (recursive) functions keep their return type annotation - TypeScript cannot infer a return type that depends on itself (TS7023), so the fixer no longer strips it there.
|
|
16
41
|
|
|
17
42
|
## 1.0.0-next.13
|
|
18
43
|
|
|
19
44
|
### Minor Changes
|
|
20
45
|
|
|
21
|
-
- [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`5a43c83`](https://github.com/ethlete-io/ethdk/commit/5a43c8347a98a815562d1bb0f59bc0db1d765262) Thanks [@github-actions](https://github.com/apps/github-actions)! - Recommended config: the `@Injectable` ban now also flags Angular 22's `@Service` decorator
|
|
46
|
+
- [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`5a43c83`](https://github.com/ethlete-io/ethdk/commit/5a43c8347a98a815562d1bb0f59bc0db1d765262) Thanks [@github-actions](https://github.com/apps/github-actions)! - Recommended config: the `@Injectable` ban now also flags Angular 22's `@Service` decorator - use `createProvider` / `createRootProvider` from `@ethlete/core` instead.
|
|
22
47
|
|
|
23
48
|
- [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`9be3738`](https://github.com/ethlete-io/ethdk/commit/9be3738e18ef1837841c79131127fa42406a3e92) Thanks [@github-actions](https://github.com/apps/github-actions)! - New template rule `ethlete/prefer-static-boolean-properties` (in `recommendedTemplate` as `warn`): flags property bindings of static booleans like `[isReadonly]="true"` and suggests the static-attribute form (`isReadonly` / `isReadonly="false"`). Suggestion-only, since the rewrite is only safe for inputs with a `booleanAttribute` transform.
|
|
24
49
|
|
|
@@ -27,8 +52,8 @@
|
|
|
27
52
|
### Minor Changes
|
|
28
53
|
|
|
29
54
|
- [`44adcac`](https://github.com/ethlete-io/ethdk/commit/44adcac94d7e0f56742e02901221c6e04da7df47) Thanks [@TomTomB](https://github.com/TomTomB)! - Add two component I/O naming rules:
|
|
30
|
-
- `no-native-html-input-name` (error)
|
|
31
|
-
- `prefer-present-tense-output` (warn)
|
|
55
|
+
- `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.
|
|
56
|
+
- `prefer-present-tense-output` (warn) - nudges `output()` names toward the present tense like native DOM events (`playerSelect`, not `playerSelected`).
|
|
32
57
|
|
|
33
58
|
The `on`-prefix case is already covered by `@angular-eslint/no-output-on-prefix`, so no rule is added for it.
|
|
34
59
|
|
|
@@ -36,7 +61,7 @@
|
|
|
36
61
|
|
|
37
62
|
### Major Changes
|
|
38
63
|
|
|
39
|
-
- [#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
|
|
64
|
+
- [#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.
|
|
40
65
|
|
|
41
66
|
## 0.1.0-next.10
|
|
42
67
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/eslint-plugin",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.17",
|
|
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.
|
|
9
|
+
"@angular/core": "22.0.7",
|
|
10
10
|
"@typescript-eslint/eslint-plugin": ">=8.0.0",
|
|
11
11
|
"eslint": ">=9.0.0",
|
|
12
12
|
"tslib": "2.8.1",
|
|
@@ -170,12 +170,12 @@ const recommendedTs = {
|
|
|
170
170
|
message:
|
|
171
171
|
'No legacy Angular lifecycle hooks. Use afterNextRender, inject(DestroyRef).onDestroy, or constructor-based effects instead.',
|
|
172
172
|
},
|
|
173
|
-
// No @Injectable / @Service — use
|
|
173
|
+
// No @Injectable / @Service — use defineRootProvider / defineProvider from @ethlete/core
|
|
174
174
|
{
|
|
175
175
|
selector:
|
|
176
176
|
'Decorator[expression.callee.name=/^(Injectable|Service)$/], Decorator[expression.name=/^(Injectable|Service)$/]',
|
|
177
177
|
message:
|
|
178
|
-
'No @Injectable / @Service services. Use
|
|
178
|
+
'No @Injectable / @Service services. Use defineRootProvider / defineProvider from @ethlete/core instead.',
|
|
179
179
|
},
|
|
180
180
|
// No route guards — handle access control inside the component itself
|
|
181
181
|
{
|
|
@@ -253,6 +253,9 @@ const recommendedTs = {
|
|
|
253
253
|
// No .subscribe() inside effect() or computed()
|
|
254
254
|
'ethlete/no-rxjs-in-effect': 'error',
|
|
255
255
|
|
|
256
|
+
// No cleanup function returned from effect() — Angular ignores it, so it never runs
|
|
257
|
+
'ethlete/no-effect-cleanup-return': 'error',
|
|
258
|
+
|
|
256
259
|
// No readonly on reactive class properties (signals, inputs, computed, inject, etc.)
|
|
257
260
|
'ethlete/no-readonly-signal': 'error',
|
|
258
261
|
|
|
@@ -292,6 +295,8 @@ const recommendedTs = {
|
|
|
292
295
|
// No empty blank lines between import declarations
|
|
293
296
|
'ethlete/no-empty-newlines-between-imports': 'error',
|
|
294
297
|
|
|
298
|
+
'ethlete/no-impure-top-level-provider': 'error',
|
|
299
|
+
|
|
295
300
|
// No legacy Angular decorators — use signal-based APIs (input, output, viewChild, etc.)
|
|
296
301
|
// and host: {} bindings (instead of @HostBinding / @HostListener)
|
|
297
302
|
'ethlete/no-legacy-angular-decorators': 'error',
|
|
@@ -343,6 +348,10 @@ const recommendedTs = {
|
|
|
343
348
|
// Remove standalone metadata — standalone is implicit and should not be declared
|
|
344
349
|
'ethlete/no-standalone-flag': 'error',
|
|
345
350
|
|
|
351
|
+
// No interpolated template literal above an inline template — it silently kills
|
|
352
|
+
// Angular language service completions for the rest of the file
|
|
353
|
+
'ethlete/no-template-literal-before-inline-template': 'error',
|
|
354
|
+
|
|
346
355
|
// ── Angular outputs ─────────────────────────────────────────────────────
|
|
347
356
|
|
|
348
357
|
// No on-prefixed outputs (onSelectDate → selectDate)
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ const noInjectChain = require('./rules/no-inject-chain');
|
|
|
5
5
|
const noTrivialReturnType = require('./rules/no-trivial-return-type');
|
|
6
6
|
const noSubscribeInPipe = require('./rules/no-subscribe-in-pipe');
|
|
7
7
|
const noRxjsInEffect = require('./rules/no-rxjs-in-effect');
|
|
8
|
+
const noEffectCleanupReturn = require('./rules/no-effect-cleanup-return');
|
|
8
9
|
const noReadonlySignal = require('./rules/no-readonly-signal');
|
|
9
10
|
const noScreamingCaseLocal = require('./rules/no-screaming-case-local');
|
|
10
11
|
const preferRxjsTimer = require('./rules/prefer-rxjs-timer');
|
|
@@ -40,6 +41,7 @@ const noUnusedClassMember = require('./rules/no-unused-class-member');
|
|
|
40
41
|
const noDomQuery = require('./rules/no-dom-query');
|
|
41
42
|
const noLeadingUnderscoreClassMember = require('./rules/no-leading-underscore-class-member');
|
|
42
43
|
const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-arrays');
|
|
44
|
+
const noTemplateLiteralBeforeInlineTemplate = require('./rules/no-template-literal-before-inline-template');
|
|
43
45
|
const noStandaloneFlag = require('./rules/no-standalone-flag');
|
|
44
46
|
const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
|
|
45
47
|
const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
|
|
@@ -51,6 +53,7 @@ const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref
|
|
|
51
53
|
const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
|
|
52
54
|
const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
|
|
53
55
|
const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
|
|
56
|
+
const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
|
|
54
57
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
55
58
|
|
|
56
59
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -64,6 +67,7 @@ const plugin = {
|
|
|
64
67
|
'no-trivial-return-type': noTrivialReturnType,
|
|
65
68
|
'no-subscribe-in-pipe': noSubscribeInPipe,
|
|
66
69
|
'no-rxjs-in-effect': noRxjsInEffect,
|
|
70
|
+
'no-effect-cleanup-return': noEffectCleanupReturn,
|
|
67
71
|
'no-readonly-signal': noReadonlySignal,
|
|
68
72
|
'no-screaming-case-local': noScreamingCaseLocal,
|
|
69
73
|
'prefer-rxjs-timer': preferRxjsTimer,
|
|
@@ -99,6 +103,7 @@ const plugin = {
|
|
|
99
103
|
'no-dom-query': noDomQuery,
|
|
100
104
|
'no-leading-underscore-class-member': noLeadingUnderscoreClassMember,
|
|
101
105
|
'no-empty-angular-metadata-arrays': noEmptyAngularMetadataArrays,
|
|
106
|
+
'no-template-literal-before-inline-template': noTemplateLiteralBeforeInlineTemplate,
|
|
102
107
|
'no-standalone-flag': noStandaloneFlag,
|
|
103
108
|
'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
|
|
104
109
|
'angular-decorator-property-order': angularDecoratorPropertyOrder,
|
|
@@ -110,6 +115,7 @@ const plugin = {
|
|
|
110
115
|
'no-native-html-input-name': noNativeHtmlInputName,
|
|
111
116
|
'prefer-present-tense-output': preferPresentTenseOutput,
|
|
112
117
|
'prefer-static-boolean-properties': preferStaticBooleanProperties,
|
|
118
|
+
'no-impure-top-level-provider': noImpureTopLevelProvider,
|
|
113
119
|
},
|
|
114
120
|
};
|
|
115
121
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows returning a cleanup function from an `effect()` callback.
|
|
6
|
+
*
|
|
7
|
+
* Angular's `effect()` ignores whatever the callback returns — the React/Svelte idiom of returning a
|
|
8
|
+
* teardown function silently does nothing, so the "cleanup" never runs. The bug is invisible: the code
|
|
9
|
+
* reads as if it unregisters, and only the leak shows up later.
|
|
10
|
+
*
|
|
11
|
+
* BAD:
|
|
12
|
+
* effect(() => {
|
|
13
|
+
* this.group?.registerItem(this);
|
|
14
|
+
*
|
|
15
|
+
* return () => this.group?.unregisterItem(this); // ❌ never called
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* GOOD — teardown tied to each re-run (the registered key is reactive):
|
|
19
|
+
* effect((onCleanup) => {
|
|
20
|
+
* const id = this.ref().id;
|
|
21
|
+
* this.stack?.registerItem(id);
|
|
22
|
+
*
|
|
23
|
+
* onCleanup(() => this.stack?.unregisterItem(id));
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* GOOD — teardown tied to the lifetime, for a registration that never changes:
|
|
27
|
+
* this.group?.registerItem(this);
|
|
28
|
+
* inject(DestroyRef).onDestroy(() => this.group?.unregisterItem(this));
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Calls whose first argument is a body that ignores its return value. */
|
|
32
|
+
const EFFECT_CALLEES = new Set(['effect', 'afterRenderEffect']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {any} node
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
const isFunctionExpression = (node) =>
|
|
39
|
+
!!node && (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {any} node
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
const isFunction = (node) => isFunctionExpression(node) || node?.type === 'FunctionDeclaration';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `return` the given function owns — returns inside nested functions belong to those, so the walk
|
|
49
|
+
* stops at each function boundary.
|
|
50
|
+
* @param {any} fn
|
|
51
|
+
* @returns {any[]}
|
|
52
|
+
*/
|
|
53
|
+
const ownReturnStatements = (fn) => {
|
|
54
|
+
/** @type {any[]} */
|
|
55
|
+
const returns = [];
|
|
56
|
+
|
|
57
|
+
/** @param {any} node */
|
|
58
|
+
const visit = (node) => {
|
|
59
|
+
if (!node || typeof node.type !== 'string') {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (node.type === 'ReturnStatement') {
|
|
64
|
+
returns.push(node);
|
|
65
|
+
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isFunction(node)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const key of Object.keys(node)) {
|
|
74
|
+
if (key === 'parent') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const value = node[key];
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
value.forEach(visit);
|
|
82
|
+
} else if (value && typeof value.type === 'string') {
|
|
83
|
+
visit(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
fn.body.body.forEach(visit);
|
|
89
|
+
|
|
90
|
+
return returns;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
94
|
+
const noEffectCleanupReturn = {
|
|
95
|
+
meta: {
|
|
96
|
+
type: 'problem',
|
|
97
|
+
docs: {
|
|
98
|
+
description: 'Disallow returning a cleanup function from an effect() callback — Angular ignores it.',
|
|
99
|
+
recommended: true,
|
|
100
|
+
},
|
|
101
|
+
messages: {
|
|
102
|
+
returnedCleanup:
|
|
103
|
+
'{{callee}}() ignores the value its callback returns, so this cleanup function never runs. ' +
|
|
104
|
+
'Register it via the onCleanup parameter — {{callee}}((onCleanup) => { …; onCleanup(() => …) }) — or, ' +
|
|
105
|
+
'when the teardown belongs to the lifetime rather than to each re-run, drop the effect and use ' +
|
|
106
|
+
'inject(DestroyRef).onDestroy(() => …).',
|
|
107
|
+
},
|
|
108
|
+
schema: [],
|
|
109
|
+
fixable: 'code',
|
|
110
|
+
},
|
|
111
|
+
create(context) {
|
|
112
|
+
const sourceCode = context.sourceCode;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
CallExpression(node) {
|
|
116
|
+
const { callee } = node;
|
|
117
|
+
|
|
118
|
+
if (callee.type !== 'Identifier' || !EFFECT_CALLEES.has(callee.name)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const callback = /** @type {any} */ (node.arguments[0]);
|
|
123
|
+
|
|
124
|
+
if (!isFunctionExpression(callback)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// effect(() => () => cleanup()) — the whole body *is* the ignored cleanup
|
|
129
|
+
if (isFunctionExpression(callback.body)) {
|
|
130
|
+
context.report({
|
|
131
|
+
node: callback.body,
|
|
132
|
+
messageId: 'returnedCleanup',
|
|
133
|
+
data: { callee: callee.name },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (callback.body.type !== 'BlockStatement') {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const returns = ownReturnStatements(callback);
|
|
144
|
+
const cleanupReturns = returns.filter((statement) => isFunctionExpression(statement.argument));
|
|
145
|
+
|
|
146
|
+
if (cleanupReturns.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
|
|
151
|
+
// back a function literal. With an existing parameter, an early `return`, or a returned reference,
|
|
152
|
+
// the intent needs a human.
|
|
153
|
+
// The parameter list's `(` — the first one before the body, so this works for `() => {}` and
|
|
154
|
+
// `function () {}` alike (their first token differs).
|
|
155
|
+
const openParen = sourceCode
|
|
156
|
+
.getTokens(callback)
|
|
157
|
+
.find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
|
|
158
|
+
const fixable = callback.params.length === 0 && returns.length === 1 && cleanupReturns.length === 1;
|
|
159
|
+
|
|
160
|
+
for (const statement of cleanupReturns) {
|
|
161
|
+
context.report({
|
|
162
|
+
node: statement.argument,
|
|
163
|
+
messageId: 'returnedCleanup',
|
|
164
|
+
data: { callee: callee.name },
|
|
165
|
+
fix:
|
|
166
|
+
fixable && openParen
|
|
167
|
+
? (fixer) => [
|
|
168
|
+
fixer.insertTextAfter(openParen, 'onCleanup'),
|
|
169
|
+
fixer.replaceText(statement, `onCleanup(${sourceCode.getText(statement.argument)});`),
|
|
170
|
+
]
|
|
171
|
+
: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
module.exports = noEffectCleanupReturn;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const PURE_ANNOTATION = /^\s*[#@]__PURE__\s*$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Angular's own app build already pure-annotates these at module scope
|
|
8
|
+
* (`pure-toplevel-functions`'s `sideEffectFreeConstructors`), so requiring it in source is noise.
|
|
9
|
+
*/
|
|
10
|
+
const AUTO_ANNOTATED_CONSTRUCTORS = new Set(['InjectionToken']);
|
|
11
|
+
|
|
12
|
+
/** Nothing inside a function body runs on import, so it needs no annotation. */
|
|
13
|
+
const NOT_EVALUATED_ON_IMPORT = new Set(['ArrowFunctionExpression', 'FunctionExpression', 'ClassExpression']);
|
|
14
|
+
|
|
15
|
+
const isModuleScope = (declarator) => {
|
|
16
|
+
const declaration = declarator.parent;
|
|
17
|
+
const container = declaration?.parent;
|
|
18
|
+
|
|
19
|
+
if (container?.type === 'Program') return true;
|
|
20
|
+
|
|
21
|
+
return container?.type === 'ExportNamedDeclaration' && container.parent?.type === 'Program';
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const hasPureAnnotation = (sourceCode, node) =>
|
|
25
|
+
sourceCode.getCommentsBefore(node).some((comment) => comment.type === 'Block' && PURE_ANNOTATION.test(comment.value));
|
|
26
|
+
|
|
27
|
+
/** `f()`, `ns.f()` and `new X()` are nameable; an IIFE is not, and is left alone. */
|
|
28
|
+
const calleeName = (node) => {
|
|
29
|
+
const callee = node.callee;
|
|
30
|
+
|
|
31
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
32
|
+
if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') return callee.property.name;
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Every call/`new` in an initializer that really is evaluated when the module is imported. */
|
|
38
|
+
const collectImportTimeCalls = (root) => {
|
|
39
|
+
const found = [];
|
|
40
|
+
|
|
41
|
+
const visit = (node) => {
|
|
42
|
+
if (!node || typeof node.type !== 'string' || NOT_EVALUATED_ON_IMPORT.has(node.type)) return;
|
|
43
|
+
|
|
44
|
+
if (node.type === 'CallExpression' || node.type === 'NewExpression') found.push(node);
|
|
45
|
+
|
|
46
|
+
for (const key of Object.keys(node)) {
|
|
47
|
+
if (key === 'parent') continue;
|
|
48
|
+
|
|
49
|
+
const child = node[key];
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(child)) child.forEach(visit);
|
|
52
|
+
else if (child && typeof child === 'object') visit(child);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
visit(root);
|
|
57
|
+
|
|
58
|
+
return found;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
62
|
+
const noImpureTopLevelProvider = {
|
|
63
|
+
meta: {
|
|
64
|
+
type: 'problem',
|
|
65
|
+
docs: {
|
|
66
|
+
description:
|
|
67
|
+
'Disallow module-scope destructuring of a factory call, and require a pure annotation on module-scope calls in library source.',
|
|
68
|
+
},
|
|
69
|
+
fixable: 'code',
|
|
70
|
+
schema: [
|
|
71
|
+
{
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
requirePureAnnotation: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
description:
|
|
77
|
+
'Also require `@__PURE__` on every call evaluated at module scope. Turn this on in publishable library source; it is pointless in application code, where every statement is retained anyway.',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
messages: {
|
|
84
|
+
noDestructuring:
|
|
85
|
+
'Do not destructure a factory call at module scope. Destructuring invokes the iterator protocol, so no bundler can drop the statement — everything the factory closes over ships to every consumer. Assign the result to one `const` and name each binding with its own annotated extractor (`toProvideFn` / `toInjectFn` / `toToken`).',
|
|
86
|
+
missingPure:
|
|
87
|
+
'This module-scope call to `{{name}}` needs a `/* @__PURE__ */` annotation, or the whole declaration is retained in every consumer bundle — one unannotated call anywhere in an initializer (including inside an argument or an object literal) is enough. If the call is genuinely not side-effect free at import time, move it inside a function instead of annotating it: library code must not do work on import.',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
create(context) {
|
|
91
|
+
const sourceCode = context.sourceCode;
|
|
92
|
+
const requirePureAnnotation = context.options[0]?.requirePureAnnotation === true;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
VariableDeclarator(node) {
|
|
96
|
+
if (!isModuleScope(node) || !node.init) return;
|
|
97
|
+
|
|
98
|
+
const initializer = node.init.type === 'TSAsExpression' ? node.init.expression : node.init;
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
initializer.type === 'CallExpression' &&
|
|
102
|
+
(node.id.type === 'ArrayPattern' || node.id.type === 'ObjectPattern')
|
|
103
|
+
) {
|
|
104
|
+
context.report({ node, messageId: 'noDestructuring' });
|
|
105
|
+
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!requirePureAnnotation || node.id.type !== 'Identifier') return;
|
|
110
|
+
|
|
111
|
+
for (const call of collectImportTimeCalls(initializer)) {
|
|
112
|
+
const name = calleeName(call);
|
|
113
|
+
|
|
114
|
+
if (name === null || hasPureAnnotation(sourceCode, call)) continue;
|
|
115
|
+
if (call.type === 'NewExpression' && AUTO_ANNOTATED_CONSTRUCTORS.has(name)) continue;
|
|
116
|
+
|
|
117
|
+
context.report({
|
|
118
|
+
node: call,
|
|
119
|
+
messageId: 'missingPure',
|
|
120
|
+
data: { name },
|
|
121
|
+
fix: (fixer) => fixer.insertTextBefore(call, '/* @__PURE__ */ '),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
module.exports = noImpureTopLevelProvider;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const ts = require('typescript');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Angular VS Code extension decides client-side whether a cursor in a `.ts` file sits inside an
|
|
8
|
+
* inline `template:` before it forwards completion / hover / definition / signature-help to the
|
|
9
|
+
* language server. That check (`isNotTypescriptOrSupportedDecoratorField` in
|
|
10
|
+
* `client/src/embedded_support.js`) walks the file with a bare `ts.createScanner()` loop.
|
|
11
|
+
*
|
|
12
|
+
* A bare scanner cannot re-scan `}` as `TemplateMiddle` / `TemplateTail` — that needs the parser's
|
|
13
|
+
* `reScanTemplateToken()`. So the first template literal containing a substitution desynchronises
|
|
14
|
+
* both the token stream and the brace counter, the scanner never recognises `template` `:` again,
|
|
15
|
+
* and every template request in the rest of the file is silently dropped. The language server
|
|
16
|
+
* answers those requests correctly; the editor just never asks.
|
|
17
|
+
*
|
|
18
|
+
* This rule reproduces that scanner verbatim, so it reports exactly the templates the extension
|
|
19
|
+
* would abandon — no heuristic, no false positives.
|
|
20
|
+
*/
|
|
21
|
+
const ANGULAR_PROPERTY_ASSIGNMENTS = new Set(['template', 'templateUrl', 'styleUrls', 'styleUrl', 'host']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('typescript').SyntaxKind} token
|
|
25
|
+
*/
|
|
26
|
+
const isPropertyAssignmentTerminator = (token) =>
|
|
27
|
+
token === ts.SyntaxKind.EndOfFileToken ||
|
|
28
|
+
token === ts.SyntaxKind.CommaToken ||
|
|
29
|
+
token === ts.SyntaxKind.SemicolonToken ||
|
|
30
|
+
token === ts.SyntaxKind.CloseBraceToken;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mirrors the extension's `isPropertyAssignmentToStringOrStringInArray`. Returns whether the editor
|
|
34
|
+
* would recognise `offset` as sitting inside a supported decorator field.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @param {number} offset
|
|
38
|
+
*/
|
|
39
|
+
const editorSeesDecoratorField = (text, offset) => {
|
|
40
|
+
const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true);
|
|
41
|
+
scanner.setText(text);
|
|
42
|
+
|
|
43
|
+
let token = scanner.scan();
|
|
44
|
+
let lastToken;
|
|
45
|
+
let lastTokenText;
|
|
46
|
+
let unclosedBraces = 0;
|
|
47
|
+
let unclosedBrackets = 0;
|
|
48
|
+
let propertyAssignmentContext = false;
|
|
49
|
+
|
|
50
|
+
while (token !== ts.SyntaxKind.EndOfFileToken && scanner.getTokenFullStart() < offset) {
|
|
51
|
+
if (
|
|
52
|
+
lastToken === ts.SyntaxKind.Identifier &&
|
|
53
|
+
lastTokenText !== undefined &&
|
|
54
|
+
token === ts.SyntaxKind.ColonToken &&
|
|
55
|
+
ANGULAR_PROPERTY_ASSIGNMENTS.has(lastTokenText)
|
|
56
|
+
) {
|
|
57
|
+
propertyAssignmentContext = true;
|
|
58
|
+
token = scanner.scan();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (unclosedBraces === 0 && unclosedBrackets === 0 && isPropertyAssignmentTerminator(token)) {
|
|
63
|
+
propertyAssignmentContext = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (token === ts.SyntaxKind.OpenBracketToken) unclosedBrackets++;
|
|
67
|
+
else if (token === ts.SyntaxKind.OpenBraceToken) unclosedBraces++;
|
|
68
|
+
else if (token === ts.SyntaxKind.CloseBracketToken) unclosedBrackets--;
|
|
69
|
+
else if (token === ts.SyntaxKind.CloseBraceToken) unclosedBraces--;
|
|
70
|
+
|
|
71
|
+
const isStringToken =
|
|
72
|
+
token === ts.SyntaxKind.StringLiteral || token === ts.SyntaxKind.NoSubstitutionTemplateLiteral;
|
|
73
|
+
const isCursorInToken =
|
|
74
|
+
scanner.getTokenFullStart() <= offset && scanner.getTokenFullStart() + scanner.getTokenText().length >= offset;
|
|
75
|
+
|
|
76
|
+
if (propertyAssignmentContext && isCursorInToken && isStringToken) return true;
|
|
77
|
+
|
|
78
|
+
lastTokenText = scanner.getTokenText();
|
|
79
|
+
lastToken = token;
|
|
80
|
+
token = scanner.scan();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('estree').Property['key']} key
|
|
88
|
+
*/
|
|
89
|
+
const getPropertyName = (key) => {
|
|
90
|
+
if (key.type === 'Identifier') return key.name;
|
|
91
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {any} node
|
|
97
|
+
*/
|
|
98
|
+
const getDecoratorName = (node) => {
|
|
99
|
+
const expression = node.expression;
|
|
100
|
+
if (!expression) return null;
|
|
101
|
+
if (expression.type === 'CallExpression') {
|
|
102
|
+
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return expression.type === 'Identifier' ? expression.name : null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The inline template text, or null when the property is not a plain non-empty string literal.
|
|
110
|
+
*
|
|
111
|
+
* @param {any} value
|
|
112
|
+
*/
|
|
113
|
+
const getInlineTemplateText = (value) => {
|
|
114
|
+
if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null;
|
|
115
|
+
if (value.type === 'TemplateLiteral' && value.expressions.length === 0) return value.quasis[0]?.value.raw ?? '';
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
120
|
+
const noTemplateLiteralBeforeInlineTemplate = {
|
|
121
|
+
meta: {
|
|
122
|
+
type: 'problem',
|
|
123
|
+
docs: {
|
|
124
|
+
description:
|
|
125
|
+
'Disallow a substituted template literal above an inline component template, which silently disables Angular language service completions for the rest of the file.',
|
|
126
|
+
},
|
|
127
|
+
schema: [],
|
|
128
|
+
messages: {
|
|
129
|
+
breaksLanguageService:
|
|
130
|
+
'The Angular language service gives no completions, hover or go-to-definition inside this template. Its editor-side scanner desynchronises on the interpolated template literal at line {{line}}, so every template request below it is dropped. Move that code below the component, extract it to a sibling file, or write it without an interpolation.',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
create(context) {
|
|
134
|
+
const sourceCode = context.sourceCode;
|
|
135
|
+
const text = sourceCode.getText();
|
|
136
|
+
|
|
137
|
+
/** @type {number[]} */
|
|
138
|
+
const interpolatedLiteralStarts = [];
|
|
139
|
+
/** @type {any[]} */
|
|
140
|
+
const inlineTemplates = [];
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
/** @param {any} node */
|
|
144
|
+
TemplateLiteral(node) {
|
|
145
|
+
if (node.expressions.length > 0) interpolatedLiteralStarts.push(node.range[0]);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
149
|
+
Decorator(node) {
|
|
150
|
+
const decoratorName = getDecoratorName(node);
|
|
151
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
152
|
+
|
|
153
|
+
const expression = /** @type {any} */ (node).expression;
|
|
154
|
+
if (expression.type !== 'CallExpression') return;
|
|
155
|
+
|
|
156
|
+
const metadata = expression.arguments[0];
|
|
157
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
158
|
+
|
|
159
|
+
for (const property of metadata.properties) {
|
|
160
|
+
if (property.type !== 'Property') continue;
|
|
161
|
+
if (getPropertyName(property.key) !== 'template') continue;
|
|
162
|
+
|
|
163
|
+
const templateText = getInlineTemplateText(property.value);
|
|
164
|
+
if (templateText === null || templateText.trim().length === 0) continue;
|
|
165
|
+
|
|
166
|
+
inlineTemplates.push(property);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
'Program:exit'() {
|
|
171
|
+
for (const property of inlineTemplates) {
|
|
172
|
+
// Where the editor would put the cursor: just inside the opening quote or backtick.
|
|
173
|
+
const cursor = property.value.range[0] + 2;
|
|
174
|
+
if (editorSeesDecoratorField(text, cursor)) continue;
|
|
175
|
+
|
|
176
|
+
const culprit = interpolatedLiteralStarts.find((start) => start < property.value.range[0]);
|
|
177
|
+
if (culprit === undefined) continue;
|
|
178
|
+
|
|
179
|
+
context.report({
|
|
180
|
+
node: property.key,
|
|
181
|
+
messageId: 'breaksLanguageService',
|
|
182
|
+
data: { line: String(sourceCode.getLocFromIndex(culprit).line) },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
module.exports = noTemplateLiteralBeforeInlineTemplate;
|