@ethlete/eslint-plugin 1.0.0-next.19 → 1.0.0-next.21
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,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.21
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`3d44765`](https://github.com/ethlete-io/ethdk/commit/3d44765647e6d47b558c1395fe5ebcb20ed3ef83) Thanks [@TomTomB](https://github.com/TomTomB)! - New rule `ethlete/no-async-await`, replacing the `no-restricted-syntax` selectors that could not be
|
|
8
|
+
toggled on their own. It also catches bare `await` and `for await…of`.
|
|
9
|
+
|
|
10
|
+
## 1.0.0-next.20
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- [`085c95e`](https://github.com/ethlete-io/ethdk/commit/085c95ebe1ecb8489b00b042066997a7a9dc8e5e) Thanks [@TomTomB](https://github.com/TomTomB)! - `require-form-submit` accepts `[etForm]` and `[formRoot]` as a form's submit handling.
|
|
15
|
+
|
|
16
|
+
- [`4350d8e`](https://github.com/ethlete-io/ethdk/commit/4350d8e1eca00447ba130ec5a0ce43a64cfc4496) Thanks [@TomTomB](https://github.com/TomTomB)! - `no-trivial-wrapper-method` no longer flags `focus`, `blur` or `reset` on a component or directive - Angular resolves those by name on the instance.
|
|
17
|
+
|
|
3
18
|
## 1.0.0-next.19
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -126,19 +126,8 @@ const recommendedTs = {
|
|
|
126
126
|
selector: 'PropertyDefinition > ArrowFunctionExpression',
|
|
127
127
|
message: 'No arrow function properties in classes. Use regular methods.',
|
|
128
128
|
},
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
selector: 'FunctionDeclaration[async=true]',
|
|
132
|
-
message: 'No async/await. Use RxJS for all async operations.',
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
selector: 'ArrowFunctionExpression[async=true]',
|
|
136
|
-
message: 'No async/await. Use RxJS for all async operations.',
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
selector: 'FunctionExpression[async=true]',
|
|
140
|
-
message: 'No async/await. Use RxJS for all async operations.',
|
|
141
|
-
},
|
|
129
|
+
// async/await is owned by ethlete/no-async-await, so a lib can keep it on while it works
|
|
130
|
+
// through the rest of this rule's backlog.
|
|
142
131
|
// No static class members — except ngTemplateContextGuard, which Angular's template
|
|
143
132
|
// type checker requires to be static (it types a structural directive's `let-` bindings)
|
|
144
133
|
{
|
|
@@ -253,6 +242,9 @@ const recommendedTs = {
|
|
|
253
242
|
// No .subscribe() inside a .pipe() callback
|
|
254
243
|
'ethlete/no-subscribe-in-pipe': 'error',
|
|
255
244
|
|
|
245
|
+
// No async/await — asynchronous work is modelled as cold Observables
|
|
246
|
+
'ethlete/no-async-await': 'error',
|
|
247
|
+
|
|
256
248
|
// No .subscribe() inside effect() or computed()
|
|
257
249
|
'ethlete/no-rxjs-in-effect': 'error',
|
|
258
250
|
|
package/src/index.js
CHANGED
|
@@ -58,6 +58,7 @@ const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-withou
|
|
|
58
58
|
const requireFormSubmit = require('./rules/require-form-submit');
|
|
59
59
|
const noCdkImport = require('./rules/no-cdk-import');
|
|
60
60
|
const noLegacyQueryImport = require('./rules/no-legacy-query-import');
|
|
61
|
+
const noAsyncAwait = require('./rules/no-async-await');
|
|
61
62
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
62
63
|
|
|
63
64
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -124,6 +125,7 @@ const plugin = {
|
|
|
124
125
|
'require-form-submit': requireFormSubmit,
|
|
125
126
|
'no-cdk-import': noCdkImport,
|
|
126
127
|
'no-legacy-query-import': noLegacyQueryImport,
|
|
128
|
+
'no-async-await': noAsyncAwait,
|
|
127
129
|
},
|
|
128
130
|
};
|
|
129
131
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallow async/await — asynchronous work is modelled with RxJS.
|
|
6
|
+
*
|
|
7
|
+
* Every async API in the SDK returns a cold Observable, not a Promise, so a consumer can compose,
|
|
8
|
+
* cancel and retry it. Bridge a browser Promise in with from(); never await it.
|
|
9
|
+
*
|
|
10
|
+
* BAD:
|
|
11
|
+
* async copy(text: string) {
|
|
12
|
+
* await navigator.clipboard.writeText(text);
|
|
13
|
+
* this.copied.set(true);
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* GOOD:
|
|
17
|
+
* copy(text: string) {
|
|
18
|
+
* return from(navigator.clipboard.writeText(text)).pipe(tap(() => this.copied.set(true)));
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Storybook's play function is called by the test runner and has to be a promise. */
|
|
23
|
+
const isStorybookPlayFunction = (node) => {
|
|
24
|
+
const { parent } = node;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
parent?.type === 'Property' &&
|
|
28
|
+
parent.value === node &&
|
|
29
|
+
parent.key.type === 'Identifier' &&
|
|
30
|
+
parent.key.name === 'play'
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
35
|
+
const noAsyncAwait = {
|
|
36
|
+
meta: {
|
|
37
|
+
type: 'problem',
|
|
38
|
+
docs: {
|
|
39
|
+
description: 'Disallow async / await — model asynchronous work as RxJS Observables.',
|
|
40
|
+
recommended: true,
|
|
41
|
+
},
|
|
42
|
+
messages: {
|
|
43
|
+
noAsync:
|
|
44
|
+
"Don't declare an 'async' function. Return a cold Observable instead — 'defer()' for laziness, 'from()' to bridge a Promise-based browser API.",
|
|
45
|
+
noAwait:
|
|
46
|
+
"Don't 'await'. Compose the value into the stream with 'switchMap' / 'mergeMap' / 'from()' instead, and read a one-shot result with 'firstValueFrom()' only in tests.",
|
|
47
|
+
noForAwait: "Don't use 'for await…of'. Bridge the async iterable into a stream and compose it with RxJS.",
|
|
48
|
+
},
|
|
49
|
+
schema: [],
|
|
50
|
+
},
|
|
51
|
+
create(context) {
|
|
52
|
+
const isExempt = (node) => {
|
|
53
|
+
for (let current = node; current; current = current.parent) {
|
|
54
|
+
if (isStorybookPlayFunction(current)) return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const reportAsync = (node) => {
|
|
61
|
+
if (!node.async || isExempt(node)) return;
|
|
62
|
+
|
|
63
|
+
context.report({ node, messageId: 'noAsync' });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
FunctionDeclaration: reportAsync,
|
|
68
|
+
FunctionExpression: reportAsync,
|
|
69
|
+
ArrowFunctionExpression: reportAsync,
|
|
70
|
+
AwaitExpression(node) {
|
|
71
|
+
if (!isExempt(node)) context.report({ node, messageId: 'noAwait' });
|
|
72
|
+
},
|
|
73
|
+
ForOfStatement(node) {
|
|
74
|
+
if (node.await && !isExempt(node)) context.report({ node, messageId: 'noForAwait' });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
module.exports = noAsyncAwait;
|
|
@@ -16,14 +16,41 @@
|
|
|
16
16
|
* GOOD:
|
|
17
17
|
* // Call this.value.set(val) directly at the call sites.
|
|
18
18
|
* // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
|
|
19
|
+
* // `focus`/`blur`/`reset` on a @Component/@Directive are exempt - see CONTRACT_METHOD_NAMES.
|
|
19
20
|
*/
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Imperative members Angular and the DOM look up **by name on the instance**. A component that
|
|
24
|
+
* wraps a control in a host directive has to re-expose them itself: signal forms resolves a custom
|
|
25
|
+
* control to the wrapper component, not to its host directives, so a `focus()` that only forwards
|
|
26
|
+
* is the whole contract - there is no call site to inline it into.
|
|
27
|
+
*/
|
|
28
|
+
const CONTRACT_METHOD_NAMES = new Set(['focus', 'blur', 'reset']);
|
|
29
|
+
|
|
21
30
|
/**
|
|
22
31
|
* Returns true when the param is a plain identifier (no default, no destructuring, no rest).
|
|
23
32
|
* @param {import('@typescript-eslint/types').TSESTree.Parameter} param
|
|
24
33
|
*/
|
|
25
34
|
const isSimpleParam = (param) => param.type === 'Identifier';
|
|
26
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether the class holding this method is an Angular component or directive.
|
|
38
|
+
* @param {import('eslint').Rule.Node} methodNode MethodDefinition node
|
|
39
|
+
*/
|
|
40
|
+
const isInAngularClass = (methodNode) => {
|
|
41
|
+
const classNode = methodNode.parent?.parent;
|
|
42
|
+
const decorators = classNode && 'decorators' in classNode ? classNode.decorators : null;
|
|
43
|
+
|
|
44
|
+
if (!decorators) return false;
|
|
45
|
+
|
|
46
|
+
return decorators.some((decorator) => {
|
|
47
|
+
const expression = decorator.expression;
|
|
48
|
+
const callee = expression.type === 'CallExpression' ? expression.callee : expression;
|
|
49
|
+
|
|
50
|
+
return callee.type === 'Identifier' && (callee.name === 'Component' || callee.name === 'Directive');
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
27
54
|
/**
|
|
28
55
|
* Returns the single CallExpression in the method body when the method is a
|
|
29
56
|
* trivial all-args-forwarding wrapper, or null otherwise.
|
|
@@ -97,6 +124,8 @@ const noTrivialWrapperMethod = {
|
|
|
97
124
|
const methodName = node.key.type === 'Identifier' ? node.key.name : null;
|
|
98
125
|
if (!methodName) return;
|
|
99
126
|
|
|
127
|
+
if (CONTRACT_METHOD_NAMES.has(methodName) && isInAngularClass(node)) return;
|
|
128
|
+
|
|
100
129
|
const callee = callExpr.callee;
|
|
101
130
|
const targetName =
|
|
102
131
|
callee.type === 'MemberExpression'
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* submit control reaches a form at all.
|
|
7
7
|
*
|
|
8
8
|
* <form (ngSubmit)="save()"> ✔
|
|
9
|
+
* <form [etForm]="form"> ✔ signal forms - the directive submits the field tree
|
|
9
10
|
* <form> ✘ pressing Enter in a field does nothing, or reloads the page
|
|
10
11
|
*
|
|
11
12
|
* <form …><button type="submit"> ✔
|
|
@@ -26,7 +27,9 @@ const hasBinding = (node, name) =>
|
|
|
26
27
|
|
|
27
28
|
/** @param {any} node */
|
|
28
29
|
const handlesSubmit = (node) =>
|
|
29
|
-
node.outputs?.some(/** @param {any} o */ (o) => o.name === 'submit' || o.name === 'ngSubmit')
|
|
30
|
+
node.outputs?.some(/** @param {any} o */ (o) => o.name === 'submit' || o.name === 'ngSubmit') ||
|
|
31
|
+
hasBinding(node, 'etForm') ||
|
|
32
|
+
hasBinding(node, 'formRoot');
|
|
30
33
|
|
|
31
34
|
/** @param {any} node */
|
|
32
35
|
const submitsNatively = (node) =>
|
|
@@ -46,7 +49,7 @@ const requireFormSubmit = {
|
|
|
46
49
|
schema: [],
|
|
47
50
|
messages: {
|
|
48
51
|
missingSubmitHandler:
|
|
49
|
-
'This `<form>` handles no submission - pressing Enter in a field either does nothing or reloads the page. Bind `(ngSubmit)` (reactive forms) or `(submit)`, or use a plain element if this is not a form.',
|
|
52
|
+
'This `<form>` handles no submission - pressing Enter in a field either does nothing or reloads the page. Bind `[etForm]` (signal forms), `(ngSubmit)` (reactive forms) or `(submit)`, or use a plain element if this is not a form.',
|
|
50
53
|
submitOutsideForm:
|
|
51
54
|
'A `type="submit"` control outside a `<form>` submits nothing. Put it inside the form, or associate it with one by id: `form="the-form-id"`.',
|
|
52
55
|
},
|