@ethlete/eslint-plugin 1.0.0-next.16 → 1.0.0-next.18
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
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.18
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`6a7e76b`](https://github.com/ethlete-io/ethdk/commit/6a7e76b3535412ac3eefdabc1aecb98c2f26552f) Thanks [@TomTomB](https://github.com/TomTomB)! - New rule `no-legacy-prepare-without-injector`: a legacy query `prepare()` called from a deferred callback
|
|
8
|
+
must pass an `injector`, with a fixer that adds the member and threads it.
|
|
9
|
+
|
|
10
|
+
## 1.0.0-next.17
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- [#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
|
|
15
|
+
`{ requirePureAnnotation: true }`, for publishable library source - requires `/* @__PURE__ */` on
|
|
16
|
+
module-scope calls. Both shapes are undroppable, so one of them puts everything the call touches into
|
|
17
|
+
every consumer's bundle. Fixable.
|
|
18
|
+
|
|
3
19
|
## 1.0.0-next.16
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
|
6
22
|
|
|
7
23
|
- [#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()` /
|
|
8
|
-
`afterRenderEffect()`, which Angular ignores
|
|
24
|
+
`afterRenderEffect()`, which Angular ignores - so the teardown silently never runs. Auto-fixes the
|
|
9
25
|
mechanical case to the `onCleanup` parameter; otherwise points at `inject(DestroyRef).onDestroy()`.
|
|
10
26
|
|
|
11
27
|
- [#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.
|
|
12
28
|
|
|
13
|
-
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`
|
|
29
|
+
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.
|
|
14
30
|
|
|
15
|
-
The new rule reproduces that scanner verbatim, so it reports exactly the templates the extension would abandon
|
|
31
|
+
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.
|
|
16
32
|
|
|
17
|
-
No public API changed
|
|
33
|
+
No public API changed - the `components` and `cdk` bumps are story/spec restructuring plus moving `signalVisibilityChangeClasses` below `RichFilterHostComponent` in the same module.
|
|
18
34
|
|
|
19
35
|
## 1.0.0-next.15
|
|
20
36
|
|
|
@@ -28,13 +44,13 @@
|
|
|
28
44
|
|
|
29
45
|
- [`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.
|
|
30
46
|
|
|
31
|
-
- [`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
|
|
47
|
+
- [`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.
|
|
32
48
|
|
|
33
49
|
## 1.0.0-next.13
|
|
34
50
|
|
|
35
51
|
### Minor Changes
|
|
36
52
|
|
|
37
|
-
- [#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
|
|
53
|
+
- [#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.
|
|
38
54
|
|
|
39
55
|
- [#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.
|
|
40
56
|
|
|
@@ -43,8 +59,8 @@
|
|
|
43
59
|
### Minor Changes
|
|
44
60
|
|
|
45
61
|
- [`44adcac`](https://github.com/ethlete-io/ethdk/commit/44adcac94d7e0f56742e02901221c6e04da7df47) Thanks [@TomTomB](https://github.com/TomTomB)! - Add two component I/O naming rules:
|
|
46
|
-
- `no-native-html-input-name` (error)
|
|
47
|
-
- `prefer-present-tense-output` (warn)
|
|
62
|
+
- `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.
|
|
63
|
+
- `prefer-present-tense-output` (warn) - nudges `output()` names toward the present tense like native DOM events (`playerSelect`, not `playerSelected`).
|
|
48
64
|
|
|
49
65
|
The `on`-prefix case is already covered by `@angular-eslint/no-output-on-prefix`, so no rule is added for it.
|
|
50
66
|
|
|
@@ -52,7 +68,7 @@
|
|
|
52
68
|
|
|
53
69
|
### Major Changes
|
|
54
70
|
|
|
55
|
-
- [#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
|
|
71
|
+
- [#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.
|
|
56
72
|
|
|
57
73
|
## 0.1.0-next.10
|
|
58
74
|
|
package/package.json
CHANGED
|
@@ -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
|
{
|
|
@@ -247,6 +247,9 @@ const recommendedTs = {
|
|
|
247
247
|
// Observable variables/properties must end with $
|
|
248
248
|
'ethlete/require-dollar-suffix': 'error',
|
|
249
249
|
|
|
250
|
+
// Legacy query creator prepare() calls that run outside an injection context need an explicit injector
|
|
251
|
+
'ethlete/no-legacy-prepare-without-injector': 'error',
|
|
252
|
+
|
|
250
253
|
// No .subscribe() inside a .pipe() callback
|
|
251
254
|
'ethlete/no-subscribe-in-pipe': 'error',
|
|
252
255
|
|
|
@@ -295,6 +298,8 @@ const recommendedTs = {
|
|
|
295
298
|
// No empty blank lines between import declarations
|
|
296
299
|
'ethlete/no-empty-newlines-between-imports': 'error',
|
|
297
300
|
|
|
301
|
+
'ethlete/no-impure-top-level-provider': 'error',
|
|
302
|
+
|
|
298
303
|
// No legacy Angular decorators — use signal-based APIs (input, output, viewChild, etc.)
|
|
299
304
|
// and host: {} bindings (instead of @HostBinding / @HostListener)
|
|
300
305
|
'ethlete/no-legacy-angular-decorators': 'error',
|
package/src/index.js
CHANGED
|
@@ -53,6 +53,8 @@ const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref
|
|
|
53
53
|
const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
|
|
54
54
|
const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
|
|
55
55
|
const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
|
|
56
|
+
const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
|
|
57
|
+
const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
|
|
56
58
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
57
59
|
|
|
58
60
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -63,6 +65,7 @@ const plugin = {
|
|
|
63
65
|
},
|
|
64
66
|
rules: {
|
|
65
67
|
'no-inject-chain': noInjectChain,
|
|
68
|
+
'no-legacy-prepare-without-injector': noLegacyPrepareWithoutInjector,
|
|
66
69
|
'no-trivial-return-type': noTrivialReturnType,
|
|
67
70
|
'no-subscribe-in-pipe': noSubscribeInPipe,
|
|
68
71
|
'no-rxjs-in-effect': noRxjsInEffect,
|
|
@@ -114,6 +117,7 @@ const plugin = {
|
|
|
114
117
|
'no-native-html-input-name': noNativeHtmlInputName,
|
|
115
118
|
'prefer-present-tense-output': preferPresentTenseOutput,
|
|
116
119
|
'prefer-static-boolean-properties': preferStaticBooleanProperties,
|
|
120
|
+
'no-impure-top-level-provider': noImpureTopLevelProvider,
|
|
117
121
|
},
|
|
118
122
|
};
|
|
119
123
|
|
|
@@ -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,493 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Requires an explicit `injector` on a legacy query creator's `prepare()` call whenever the call runs
|
|
6
|
+
* outside the injection context that created it.
|
|
7
|
+
*
|
|
8
|
+
* The v2 client needed no injection context, so migrated call sites are full of callbacks that prepare a
|
|
9
|
+
* query long after their component was built - a `computed()` at a class field, an `effect()` in a
|
|
10
|
+
* constructor, an RxJS operator. Those throw `ET950` at runtime, and only on the code path that runs the
|
|
11
|
+
* callback, which is how they reach production.
|
|
12
|
+
*
|
|
13
|
+
* BAD — the callback runs once the field initializer's context is long gone:
|
|
14
|
+
* users = computed(() => legacyGetUsers.prepare({ queryParams: { page: this.page() } })); // ❌ ET950
|
|
15
|
+
*
|
|
16
|
+
* GOOD:
|
|
17
|
+
* private injector = inject(Injector);
|
|
18
|
+
* users = computed(() => legacyGetUsers.prepare({ queryParams: { page: this.page() }, injector: this.injector }));
|
|
19
|
+
*
|
|
20
|
+
* Left alone: calls that really do run inside a context - directly in a constructor or field initializer,
|
|
21
|
+
* inside `runInInjectionContext()`, inside the `queryComputed` family, in a synchronous array callback
|
|
22
|
+
* (`items.map(…)`) that runs before its caller returns, or in a function that calls `inject()` itself and
|
|
23
|
+
* therefore can only be called from a context.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Helpers that run their callback inside an injection context. */
|
|
27
|
+
const CONTEXT_PROVIDING_CALLEES = new Set([
|
|
28
|
+
'runInInjectionContext',
|
|
29
|
+
'queryComputed',
|
|
30
|
+
'queryComputedTillTruthy',
|
|
31
|
+
'queryArrayComputed',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Array methods, which call back synchronously in the caller's own context. Matched only on a property
|
|
36
|
+
* access (`items.map(…)`) - the bare-identifier form is an RxJS operator, whose callback runs later.
|
|
37
|
+
*/
|
|
38
|
+
const TRANSPARENT_ARRAY_METHODS = new Set([
|
|
39
|
+
'map',
|
|
40
|
+
'flatMap',
|
|
41
|
+
'filter',
|
|
42
|
+
'forEach',
|
|
43
|
+
'find',
|
|
44
|
+
'findLast',
|
|
45
|
+
'findIndex',
|
|
46
|
+
'some',
|
|
47
|
+
'every',
|
|
48
|
+
'reduce',
|
|
49
|
+
'reduceRight',
|
|
50
|
+
'sort',
|
|
51
|
+
'flat',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {any} node
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
const isFunctionNode = (node) =>
|
|
59
|
+
!!node &&
|
|
60
|
+
(node.type === 'ArrowFunctionExpression' ||
|
|
61
|
+
node.type === 'FunctionExpression' ||
|
|
62
|
+
node.type === 'FunctionDeclaration');
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The call a function literal is being passed to, if it is an argument rather than a value.
|
|
66
|
+
* @param {any} node
|
|
67
|
+
*/
|
|
68
|
+
const getCallbackHost = (node) => {
|
|
69
|
+
const parent = node.parent;
|
|
70
|
+
|
|
71
|
+
if (!parent || parent.type !== 'CallExpression' || !parent.arguments.includes(node)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return parent;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {any} host
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
const isContextProvidingHost = (host) => {
|
|
83
|
+
if (!host) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (host.callee.type === 'Identifier') {
|
|
88
|
+
return CONTEXT_PROVIDING_CALLEES.has(host.callee.name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
host.callee.type === 'MemberExpression' &&
|
|
93
|
+
host.callee.property.type === 'Identifier' &&
|
|
94
|
+
host.callee.property.name === 'runInContext'
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {any} host
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
const isTransparentHost = (host) =>
|
|
103
|
+
!!host &&
|
|
104
|
+
host.callee.type === 'MemberExpression' &&
|
|
105
|
+
host.callee.property.type === 'Identifier' &&
|
|
106
|
+
TRANSPARENT_ARRAY_METHODS.has(host.callee.property.name);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether the function's own body calls `inject()`. Such a function can only be called from an injection
|
|
110
|
+
* context, so anything inside it has one too.
|
|
111
|
+
* @param {any} fn
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
const callsInject = (fn) => {
|
|
115
|
+
let found = false;
|
|
116
|
+
|
|
117
|
+
/** @param {any} node */
|
|
118
|
+
const visit = (node) => {
|
|
119
|
+
if (found || !node || typeof node.type !== 'string') {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name.startsWith('inject')) {
|
|
124
|
+
found = true;
|
|
125
|
+
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (node !== fn && isFunctionNode(node)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const key of Object.keys(node)) {
|
|
134
|
+
if (key === 'parent') {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const value = node[key];
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
value.forEach(visit);
|
|
142
|
+
} else if (value && typeof value.type === 'string') {
|
|
143
|
+
visit(value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
visit(fn);
|
|
149
|
+
|
|
150
|
+
return found;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether the call needs an injector, decided by the **innermost** function boundary it sits behind - a
|
|
155
|
+
* callback defined in a constructor is not "in the constructor" by the time it runs.
|
|
156
|
+
* @param {any} node
|
|
157
|
+
* @returns {{ needsInjector: boolean; boundary: string }}
|
|
158
|
+
*/
|
|
159
|
+
const classifyCallSite = (node) => {
|
|
160
|
+
let current = node;
|
|
161
|
+
|
|
162
|
+
while (current) {
|
|
163
|
+
if (isFunctionNode(current)) {
|
|
164
|
+
const parent = current.parent;
|
|
165
|
+
|
|
166
|
+
if (parent?.type === 'MethodDefinition') {
|
|
167
|
+
return parent.kind === 'constructor'
|
|
168
|
+
? { needsInjector: false, boundary: 'constructor' }
|
|
169
|
+
: { needsInjector: true, boundary: parent.kind === 'method' ? 'method' : `${parent.kind} accessor` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const host = getCallbackHost(current);
|
|
173
|
+
|
|
174
|
+
if (isContextProvidingHost(host)) {
|
|
175
|
+
return { needsInjector: false, boundary: 'injection context' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!isTransparentHost(host)) {
|
|
179
|
+
if (!host && callsInject(current)) {
|
|
180
|
+
return { needsInjector: false, boundary: 'function that injects' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
needsInjector: true,
|
|
185
|
+
boundary: host?.callee.type === 'Identifier' ? `${host.callee.name}() callback` : 'callback',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (current.type === 'PropertyDefinition') {
|
|
191
|
+
return { needsInjector: false, boundary: 'field initializer' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
current = current.parent;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { needsInjector: true, boundary: 'call site' };
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {any} node
|
|
202
|
+
* @returns {any}
|
|
203
|
+
*/
|
|
204
|
+
const findEnclosingClassBody = (node) => {
|
|
205
|
+
let current = node;
|
|
206
|
+
|
|
207
|
+
while (current) {
|
|
208
|
+
if (current.type === 'ClassBody') {
|
|
209
|
+
return current;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
current = current.parent;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return null;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {any} node
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
const isInjectInjectorCall = (node) =>
|
|
223
|
+
!!node &&
|
|
224
|
+
node.type === 'CallExpression' &&
|
|
225
|
+
node.callee.type === 'Identifier' &&
|
|
226
|
+
node.callee.name === 'inject' &&
|
|
227
|
+
node.arguments[0]?.type === 'Identifier' &&
|
|
228
|
+
node.arguments[0].name === 'Injector';
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {any} classBody
|
|
232
|
+
* @returns {string | null}
|
|
233
|
+
*/
|
|
234
|
+
const findInjectorMemberName = (classBody) => {
|
|
235
|
+
for (const member of classBody.body) {
|
|
236
|
+
if (
|
|
237
|
+
member.type === 'PropertyDefinition' &&
|
|
238
|
+
isInjectInjectorCall(member.value) &&
|
|
239
|
+
member.key.type === 'Identifier'
|
|
240
|
+
) {
|
|
241
|
+
return member.key.name;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return null;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
249
|
+
const noLegacyPrepareWithoutInjector = {
|
|
250
|
+
meta: {
|
|
251
|
+
type: 'problem',
|
|
252
|
+
docs: {
|
|
253
|
+
description:
|
|
254
|
+
'Require an explicit injector on legacy query creator prepare() calls that run outside an injection context.',
|
|
255
|
+
recommended: true,
|
|
256
|
+
},
|
|
257
|
+
messages: {
|
|
258
|
+
missingInjector:
|
|
259
|
+
'{{creator}}.prepare() runs from a {{boundary}}, outside the injection context that created it, so it ' +
|
|
260
|
+
'needs an explicit injector - without one it throws ET950 the first time that code path runs. Capture an ' +
|
|
261
|
+
'injector where a context does exist and pass it: {{creator}}.prepare({ …, injector: this.injector }).',
|
|
262
|
+
},
|
|
263
|
+
schema: [
|
|
264
|
+
{
|
|
265
|
+
type: 'object',
|
|
266
|
+
properties: {
|
|
267
|
+
creatorPattern: { type: 'string' },
|
|
268
|
+
},
|
|
269
|
+
additionalProperties: false,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
fixable: 'code',
|
|
273
|
+
},
|
|
274
|
+
create(context) {
|
|
275
|
+
const sourceCode = context.sourceCode;
|
|
276
|
+
const creatorPattern = new RegExp(context.options[0]?.creatorPattern ?? '^legacy');
|
|
277
|
+
/** Names known to be legacy creators: imported under the naming convention, or declared locally. */
|
|
278
|
+
const creatorNames = new Set();
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {any} classBody
|
|
282
|
+
* @param {string} injectorName
|
|
283
|
+
*/
|
|
284
|
+
const buildInjectorMemberFix = (classBody, injectorName) => {
|
|
285
|
+
const firstMember = classBody.body[0];
|
|
286
|
+
const indentation = ' '.repeat(firstMember ? firstMember.loc.start.column : 2);
|
|
287
|
+
const declaration = `private ${injectorName} = inject(Injector);`;
|
|
288
|
+
|
|
289
|
+
return firstMember
|
|
290
|
+
? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(firstMember, `${declaration}\n\n${indentation}`)
|
|
291
|
+
: /** @param {any} fixer */ (fixer) => fixer.replaceText(classBody, `{\n${indentation}${declaration}\n}`);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Adds the missing `inject` / `Injector` specifiers to an existing `@angular/core` import, or writes a
|
|
296
|
+
* new import. Returns `null` when the shape is one a fixer should not touch (a namespace import).
|
|
297
|
+
* @param {string[]} needed
|
|
298
|
+
*/
|
|
299
|
+
const buildCoreImportFix = (needed) => {
|
|
300
|
+
const coreImport = /** @type {any} */ (
|
|
301
|
+
sourceCode.ast.body.find(
|
|
302
|
+
(node) =>
|
|
303
|
+
node.type === 'ImportDeclaration' &&
|
|
304
|
+
node.source.type === 'Literal' &&
|
|
305
|
+
node.source.value === '@angular/core',
|
|
306
|
+
)
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
if (!coreImport) {
|
|
310
|
+
const imports = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
311
|
+
const lastImport = imports[imports.length - 1];
|
|
312
|
+
const text = `import { ${needed.join(', ')} } from '@angular/core';`;
|
|
313
|
+
|
|
314
|
+
return lastImport
|
|
315
|
+
? /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastImport, `\n${text}`)
|
|
316
|
+
: /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(sourceCode.ast.body[0], `${text}\n\n`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const specifiers = coreImport.specifiers ?? [];
|
|
320
|
+
|
|
321
|
+
if (specifiers.some(/** @param {any} specifier */ (specifier) => specifier.type !== 'ImportSpecifier')) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const existing = new Set(specifiers.map(/** @param {any} specifier */ (specifier) => specifier.imported.name));
|
|
326
|
+
const missing = needed.filter((name) => !existing.has(name));
|
|
327
|
+
const lastSpecifier = specifiers[specifiers.length - 1];
|
|
328
|
+
|
|
329
|
+
if (missing.length === 0 || !lastSpecifier) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastSpecifier, `, ${missing.join(', ')}`);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {any} callNode
|
|
338
|
+
* @param {string} injectorReference
|
|
339
|
+
*/
|
|
340
|
+
const buildArgumentFix = (callNode, injectorReference) => {
|
|
341
|
+
const argument = callNode.arguments[0];
|
|
342
|
+
const injectorProperty = `injector: ${injectorReference}`;
|
|
343
|
+
|
|
344
|
+
if (!argument) {
|
|
345
|
+
const closingParen = sourceCode.getLastToken(callNode);
|
|
346
|
+
|
|
347
|
+
return closingParen
|
|
348
|
+
? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(closingParen, `{ ${injectorProperty} }`)
|
|
349
|
+
: null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (argument.type === 'ObjectExpression') {
|
|
353
|
+
const lastProperty = argument.properties[argument.properties.length - 1];
|
|
354
|
+
|
|
355
|
+
if (!lastProperty) {
|
|
356
|
+
return /** @param {any} fixer */ (fixer) => fixer.replaceText(argument, `{ ${injectorProperty} }`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const isMultiline = argument.loc.start.line !== argument.loc.end.line;
|
|
360
|
+
const tokenAfter = sourceCode.getTokenAfter(lastProperty);
|
|
361
|
+
const hasTrailingComma = tokenAfter?.type === 'Punctuator' && tokenAfter.value === ',';
|
|
362
|
+
const indentation = isMultiline ? `\n${' '.repeat(lastProperty.loc.start.column)}` : ' ';
|
|
363
|
+
|
|
364
|
+
return /** @param {any} fixer */ (fixer) =>
|
|
365
|
+
fixer.insertTextAfter(
|
|
366
|
+
hasTrailingComma ? tokenAfter : lastProperty,
|
|
367
|
+
`${hasTrailingComma ? '' : ','}${indentation}${injectorProperty}${isMultiline ? ',' : ''}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// `prepare(args)` - spreading is the only way to keep whatever the variable holds.
|
|
372
|
+
if (argument.type === 'Identifier' || argument.type === 'MemberExpression') {
|
|
373
|
+
return /** @param {any} fixer */ (fixer) =>
|
|
374
|
+
fixer.replaceText(argument, `{ ...${sourceCode.getText(argument)}, ${injectorProperty} }`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return null;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
// Collected up front rather than in an `ImportDeclaration` visitor: a class can sit above the
|
|
382
|
+
// `export const legacyX = createLegacyQueryCreator(…)` it uses.
|
|
383
|
+
Program(program) {
|
|
384
|
+
/** @param {any} node */
|
|
385
|
+
const visit = (node) => {
|
|
386
|
+
if (!node || typeof node.type !== 'string') {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (node.type === 'ImportDeclaration') {
|
|
391
|
+
node.specifiers.forEach(
|
|
392
|
+
/** @param {any} specifier */ (specifier) => {
|
|
393
|
+
if (specifier.type === 'ImportSpecifier' && creatorPattern.test(specifier.local.name)) {
|
|
394
|
+
creatorNames.add(specifier.local.name);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
node.type === 'VariableDeclarator' &&
|
|
402
|
+
node.id.type === 'Identifier' &&
|
|
403
|
+
node.init?.type === 'CallExpression' &&
|
|
404
|
+
node.init.callee.type === 'Identifier' &&
|
|
405
|
+
node.init.callee.name === 'createLegacyQueryCreator'
|
|
406
|
+
) {
|
|
407
|
+
creatorNames.add(node.id.name);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (const key of Object.keys(node)) {
|
|
411
|
+
if (key === 'parent') {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const value = node[key];
|
|
416
|
+
|
|
417
|
+
if (Array.isArray(value)) {
|
|
418
|
+
value.forEach(visit);
|
|
419
|
+
} else if (value && typeof value.type === 'string') {
|
|
420
|
+
visit(value);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
visit(program);
|
|
426
|
+
},
|
|
427
|
+
CallExpression(node) {
|
|
428
|
+
const callee = /** @type {any} */ (node.callee);
|
|
429
|
+
|
|
430
|
+
if (
|
|
431
|
+
callee.type !== 'MemberExpression' ||
|
|
432
|
+
callee.property.type !== 'Identifier' ||
|
|
433
|
+
callee.property.name !== 'prepare' ||
|
|
434
|
+
callee.object.type !== 'Identifier' ||
|
|
435
|
+
!creatorNames.has(callee.object.name)
|
|
436
|
+
) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const firstArgument = /** @type {any} */ (node.arguments[0]);
|
|
441
|
+
const hasInjector =
|
|
442
|
+
firstArgument?.type === 'ObjectExpression' &&
|
|
443
|
+
firstArgument.properties.some(
|
|
444
|
+
/** @param {any} property */ (property) =>
|
|
445
|
+
property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'injector',
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
if (hasInjector) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const { needsInjector, boundary } = classifyCallSite(node);
|
|
453
|
+
|
|
454
|
+
if (!needsInjector) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const classBody = findEnclosingClassBody(node);
|
|
459
|
+
const existingInjector = classBody ? findInjectorMemberName(classBody) : null;
|
|
460
|
+
const injectorName = existingInjector ?? 'injector';
|
|
461
|
+
const argumentFix = classBody ? buildArgumentFix(node, `this.${injectorName}`) : null;
|
|
462
|
+
|
|
463
|
+
context.report({
|
|
464
|
+
node,
|
|
465
|
+
messageId: 'missingInjector',
|
|
466
|
+
data: { creator: callee.object.name, boundary },
|
|
467
|
+
// Only the in-class shape is auto-fixed: a standalone function has no obvious injector to reach
|
|
468
|
+
// for, and inventing an `inject()` call there would move the failure rather than fix it.
|
|
469
|
+
fix:
|
|
470
|
+
classBody && argumentFix
|
|
471
|
+
? (fixer) => {
|
|
472
|
+
const fixes = [argumentFix(fixer)];
|
|
473
|
+
|
|
474
|
+
if (!existingInjector) {
|
|
475
|
+
fixes.push(buildInjectorMemberFix(classBody, injectorName)(fixer));
|
|
476
|
+
|
|
477
|
+
const importFix = buildCoreImportFix(['inject', 'Injector']);
|
|
478
|
+
|
|
479
|
+
if (importFix) {
|
|
480
|
+
fixes.push(importFix(fixer));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return fixes;
|
|
485
|
+
}
|
|
486
|
+
: null,
|
|
487
|
+
});
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
module.exports = noLegacyPrepareWithoutInjector;
|