@ethlete/eslint-plugin 1.0.0-next.17 → 1.0.0-next.19
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 +13 -0
- package/package.json +1 -1
- package/src/configs/recommended.js +5 -0
- package/src/index.js +8 -0
- package/src/rules/no-cdk-import.js +154 -0
- package/src/rules/no-legacy-prepare-without-injector.js +493 -0
- package/src/rules/no-legacy-query-import.js +136 -0
- package/src/rules/require-form-submit.js +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.19
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3055](https://github.com/ethlete-io/ethdk/pull/3055) [`2fcb8e3`](https://github.com/ethlete-io/ethdk/commit/2fcb8e3799169956571534b75fdc152acc983877) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `require-form-submit` (a form handles its own submission, a submit control reaches a form) plus the opt-in `no-cdk-import` and `no-legacy-query-import`, which name each legacy symbol's successor in the message.
|
|
8
|
+
|
|
9
|
+
## 1.0.0-next.18
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [`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
|
|
14
|
+
must pass an `injector`, with a fixer that adds the member and threads it.
|
|
15
|
+
|
|
3
16
|
## 1.0.0-next.17
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -419,6 +422,8 @@ const recommendedTemplate = {
|
|
|
419
422
|
// (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
|
|
420
423
|
// which a template rule cannot verify)
|
|
421
424
|
'ethlete/prefer-static-boolean-properties': 'warn',
|
|
425
|
+
// A form must handle its own submission, and a type="submit" control must reach a form
|
|
426
|
+
'ethlete/require-form-submit': 'error',
|
|
422
427
|
},
|
|
423
428
|
};
|
|
424
429
|
|
package/src/index.js
CHANGED
|
@@ -54,6 +54,10 @@ 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
56
|
const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
|
|
57
|
+
const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
|
|
58
|
+
const requireFormSubmit = require('./rules/require-form-submit');
|
|
59
|
+
const noCdkImport = require('./rules/no-cdk-import');
|
|
60
|
+
const noLegacyQueryImport = require('./rules/no-legacy-query-import');
|
|
57
61
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
58
62
|
|
|
59
63
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -64,6 +68,7 @@ const plugin = {
|
|
|
64
68
|
},
|
|
65
69
|
rules: {
|
|
66
70
|
'no-inject-chain': noInjectChain,
|
|
71
|
+
'no-legacy-prepare-without-injector': noLegacyPrepareWithoutInjector,
|
|
67
72
|
'no-trivial-return-type': noTrivialReturnType,
|
|
68
73
|
'no-subscribe-in-pipe': noSubscribeInPipe,
|
|
69
74
|
'no-rxjs-in-effect': noRxjsInEffect,
|
|
@@ -116,6 +121,9 @@ const plugin = {
|
|
|
116
121
|
'prefer-present-tense-output': preferPresentTenseOutput,
|
|
117
122
|
'prefer-static-boolean-properties': preferStaticBooleanProperties,
|
|
118
123
|
'no-impure-top-level-provider': noImpureTopLevelProvider,
|
|
124
|
+
'require-form-submit': requireFormSubmit,
|
|
125
|
+
'no-cdk-import': noCdkImport,
|
|
126
|
+
'no-legacy-query-import': noLegacyQueryImport,
|
|
119
127
|
},
|
|
120
128
|
};
|
|
121
129
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createRequire } = require('node:module');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Disallows importing from `@ethlete/cdk`, the maintenance-mode predecessor of `@ethlete/components`,
|
|
10
|
+
* and names the successor of each imported symbol in the message.
|
|
11
|
+
*
|
|
12
|
+
* import { ButtonComponent } from '@ethlete/cdk';
|
|
13
|
+
* → `ButtonComponent` is legacy @ethlete/cdk. Use `ButtonComponent` from @ethlete/components
|
|
14
|
+
* (https://…/components/button): a real button system - variant, size and color inputs plus
|
|
15
|
+
* theming instead of CSS-only classes.
|
|
16
|
+
*
|
|
17
|
+
* The successors come from `migration-map.json`, which ships inside the `@ethlete/cdk` package - so
|
|
18
|
+
* the advice is always the installed version's, and this rule holds no copy of it. Without the map on
|
|
19
|
+
* disk the rule still reports, pointing at the migration guide instead of a specific symbol.
|
|
20
|
+
*
|
|
21
|
+
* Off by default: it is only useful once an app has decided to leave the cdk behind.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const CDK_PACKAGE = '@ethlete/cdk';
|
|
25
|
+
const MIGRATION_MAP = '@ethlete/cdk/migration-map.json';
|
|
26
|
+
const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
|
|
27
|
+
|
|
28
|
+
/** @type {Map<string, Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }> | null>} */
|
|
29
|
+
const mapCache = new Map();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string | undefined} mapPath
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
*/
|
|
35
|
+
const loadMigrationMap = (mapPath, cwd) => {
|
|
36
|
+
const cacheKey = mapPath ? path.resolve(cwd, mapPath) : MIGRATION_MAP;
|
|
37
|
+
|
|
38
|
+
if (mapCache.has(cacheKey)) return mapCache.get(cacheKey) ?? null;
|
|
39
|
+
|
|
40
|
+
let map;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
map = mapPath
|
|
44
|
+
? JSON.parse(fs.readFileSync(cacheKey, 'utf8'))
|
|
45
|
+
: createRequire(path.join(cwd, 'noop.js'))(MIGRATION_MAP);
|
|
46
|
+
} catch {
|
|
47
|
+
map = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mapCache.set(cacheKey, map);
|
|
51
|
+
|
|
52
|
+
return map;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** @param {any} specifier */
|
|
56
|
+
const importedName = (specifier) => {
|
|
57
|
+
if (specifier.type !== 'ImportSpecifier') return null;
|
|
58
|
+
|
|
59
|
+
return specifier.imported.name ?? specifier.imported.value ?? null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
63
|
+
const noCdkImport = {
|
|
64
|
+
meta: {
|
|
65
|
+
type: 'suggestion',
|
|
66
|
+
docs: {
|
|
67
|
+
description: 'Disallow importing from the maintenance-mode @ethlete/cdk; name the @ethlete/components successor.',
|
|
68
|
+
},
|
|
69
|
+
schema: [
|
|
70
|
+
{
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
/** Where to read the migration map from, when `@ethlete/cdk` is not resolvable from the linted project. */
|
|
74
|
+
migrationMapPath: { type: 'string' },
|
|
75
|
+
/** Base URL the migration map's doc paths are appended to. */
|
|
76
|
+
docsBaseUrl: { type: 'string' },
|
|
77
|
+
},
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
messages: {
|
|
82
|
+
successor: '`{{ name }}` is legacy {{ cdk }}. Use `{{ to }}` from {{ package }} instead ({{ docs }}).{{ note }}',
|
|
83
|
+
noSuccessor: '`{{ name }}` is legacy {{ cdk }} and has no successor{{ note }} - see {{ docs }}.',
|
|
84
|
+
unmapped: '`{{ name }}` is legacy {{ cdk }}. Move it to @ethlete/components - see {{ docs }}.',
|
|
85
|
+
module: 'Do not depend on the legacy {{ cdk }} - move to @ethlete/components, see {{ docs }}.',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
const options = context.options[0] ?? {};
|
|
90
|
+
const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
|
|
91
|
+
const migrationDocs = `${docsBaseUrl}/cdk/migration`;
|
|
92
|
+
const map = loadMigrationMap(options.migrationMapPath, context.cwd);
|
|
93
|
+
|
|
94
|
+
/** @param {string} name */
|
|
95
|
+
const entryFor = (name) => map?.[name] ?? null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ImportDeclaration(node) {
|
|
99
|
+
const declaration = /** @type {any} */ (node);
|
|
100
|
+
const source = declaration.source.value;
|
|
101
|
+
|
|
102
|
+
if (typeof source !== 'string' || (source !== CDK_PACKAGE && !source.startsWith(`${CDK_PACKAGE}/`))) return;
|
|
103
|
+
|
|
104
|
+
const named = declaration.specifiers.filter(/** @param {any} s */ (s) => importedName(s));
|
|
105
|
+
|
|
106
|
+
if (!named.length) {
|
|
107
|
+
context.report({ node, messageId: 'module', data: { cdk: CDK_PACKAGE, docs: migrationDocs } });
|
|
108
|
+
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const specifier of named) {
|
|
113
|
+
const name = /** @type {string} */ (importedName(specifier));
|
|
114
|
+
const entry = entryFor(name);
|
|
115
|
+
|
|
116
|
+
if (!entry) {
|
|
117
|
+
context.report({
|
|
118
|
+
node: specifier,
|
|
119
|
+
messageId: 'unmapped',
|
|
120
|
+
data: { name, cdk: CDK_PACKAGE, docs: migrationDocs },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!entry.to) {
|
|
127
|
+
context.report({
|
|
128
|
+
node: specifier,
|
|
129
|
+
messageId: 'noSuccessor',
|
|
130
|
+
data: { name, cdk: CDK_PACKAGE, note: entry.note ? ` (${entry.note})` : '', docs: migrationDocs },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
context.report({
|
|
137
|
+
node: specifier,
|
|
138
|
+
messageId: 'successor',
|
|
139
|
+
data: {
|
|
140
|
+
name,
|
|
141
|
+
cdk: CDK_PACKAGE,
|
|
142
|
+
to: entry.to,
|
|
143
|
+
package: entry.package ?? '@ethlete/components',
|
|
144
|
+
docs: entry.docs ? `${docsBaseUrl}${entry.docs}` : migrationDocs,
|
|
145
|
+
note: entry.note ? ` ${entry.note}.` : '',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
module.exports = noCdkImport;
|
|
@@ -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;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows importing the legacy (v2) query system from `@ethlete/query`, and names the current-system
|
|
6
|
+
* API in the message.
|
|
7
|
+
*
|
|
8
|
+
* import { V2QueryClient, filterSuccess } from '@ethlete/query';
|
|
9
|
+
* → `V2QueryClient` is the legacy (v2) query system. Use `createQueryClient` instead (…/query/queries).
|
|
10
|
+
* → `filterSuccess` … Use `query.response()` instead - the query is already signals (…/query/queries).
|
|
11
|
+
*
|
|
12
|
+
* Two things are matched: every `V2`/`AnyV2`-prefixed export - the prefix the library gives the legacy
|
|
13
|
+
* system's half of a colliding name - and the legacy APIs that never collided, which carry their
|
|
14
|
+
* successor from the migration guide. `createLegacyQueryCreator` is deliberately **not** matched: it is
|
|
15
|
+
* the sanctioned interop seam a migration leans on until its call sites are converted.
|
|
16
|
+
*
|
|
17
|
+
* Off by default, and deliberately not type-aware: it names successors rather than repeating the
|
|
18
|
+
* `@deprecated` tag every legacy export already carries. For the whole deprecated surface (including
|
|
19
|
+
* the types this rule leaves alone), enable `@typescript-eslint/no-deprecated` alongside it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const QUERY_PACKAGE = '@ethlete/query';
|
|
23
|
+
const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The legacy APIs whose names never collided with the current system, and what the migration guide
|
|
27
|
+
* (`/query/legacy#migrating-to-the-current-system`) maps each one to.
|
|
28
|
+
*
|
|
29
|
+
* @type {Record<string, { to: string, docs: string }>}
|
|
30
|
+
*/
|
|
31
|
+
const LEGACY_SYMBOLS = {
|
|
32
|
+
def: { to: 'the type parameter of a current creator - `createGetQuery(client)<TArgs>(route)`', docs: '/query/http' },
|
|
33
|
+
BasicAuthProvider: { to: 'no equivalent - the current system authenticates through `createBearerAuthProvider`', docs: '/query/auth' }, // prettier-ignore
|
|
34
|
+
CustomHeaderAuthProvider: { to: '`headers` on `createQueryClient`, which re-reads a function form per request', docs: '/query/queries#the-query-client' }, // prettier-ignore
|
|
35
|
+
EntityStore: { to: 'nothing directly - caching dedupes by request, and shared state derives from signals', docs: '/query/caching' }, // prettier-ignore
|
|
36
|
+
InfinityQuery: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
37
|
+
InfinityQueryDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
38
|
+
InfinityQueryTriggerDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
39
|
+
createInfinityQueryConfig: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
|
|
40
|
+
QueryDirective: { to: "the query's own signals, read directly in the template", docs: '/query/migrating-from-v2#templates-read-signals-not-directives' }, // prettier-ignore
|
|
41
|
+
filterSuccess: { to: '`query.response()`, or `query.response.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
42
|
+
filterFailure: { to: '`query.error()`, or `query.error.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
43
|
+
switchQueryState: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
44
|
+
takeUntilResponse: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
45
|
+
toQuerySignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' },
|
|
46
|
+
queryStateSignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' }, // prettier-ignore
|
|
47
|
+
queryStateResponseSignal: { to: '`query.response()`', docs: '/query/queries#the-query-object' },
|
|
48
|
+
queryStateErrorSignal: { to: '`query.error()`', docs: '/query/queries#the-query-object' },
|
|
49
|
+
queryStateLoadingSignal: { to: '`query.loading()`', docs: '/query/queries#the-query-object' },
|
|
50
|
+
validateWithV2Query: { to: '`validateWithQuery`', docs: '/query/errors#validating-against-the-server-as-the-user-types' }, // prettier-ignore
|
|
51
|
+
provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query-devtools`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The current-system counterparts of the names the legacy system had to give up its half of. */
|
|
55
|
+
const V2_SUCCESSORS = {
|
|
56
|
+
V2QueryClient: { to: '`createQueryClient`', docs: '/query/queries#the-query-client' },
|
|
57
|
+
V2QueryClientConfig: { to: 'the config of `createQueryClient`', docs: '/query/queries#the-query-client' },
|
|
58
|
+
V2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
|
|
59
|
+
AnyV2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
|
|
60
|
+
V2BearerAuthProvider: { to: '`createBearerAuthProvider` plus the secure creator templates', docs: '/query/auth' },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** @param {any} specifier */
|
|
64
|
+
const importedName = (specifier) => {
|
|
65
|
+
if (specifier.type !== 'ImportSpecifier') return null;
|
|
66
|
+
|
|
67
|
+
return specifier.imported.name ?? specifier.imported.value ?? null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** @param {string} name */
|
|
71
|
+
const isV2Symbol = (name) => /^(V2|AnyV2)[A-Z]/.test(name);
|
|
72
|
+
|
|
73
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
74
|
+
const noLegacyQueryImport = {
|
|
75
|
+
meta: {
|
|
76
|
+
type: 'suggestion',
|
|
77
|
+
docs: {
|
|
78
|
+
description: 'Disallow importing the legacy (v2) query system; name the current-system API instead.',
|
|
79
|
+
},
|
|
80
|
+
schema: [
|
|
81
|
+
{
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
/** Base URL the guide paths in the messages are appended to. */
|
|
85
|
+
docsBaseUrl: { type: 'string' },
|
|
86
|
+
},
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
messages: {
|
|
91
|
+
successor: '`{{ name }}` is the legacy (v2) query system. Use {{ to }} instead ({{ docs }}).',
|
|
92
|
+
legacySystem:
|
|
93
|
+
'`{{ name }}` is the legacy (v2) query system. Migrate to the current one - see {{ docs }}, and run `nx g @ethlete/query:migrate-to-query-v3` for the mechanical parts.',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
create(context) {
|
|
97
|
+
const options = context.options[0] ?? {};
|
|
98
|
+
const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ImportDeclaration(node) {
|
|
102
|
+
const declaration = /** @type {any} */ (node);
|
|
103
|
+
|
|
104
|
+
if (declaration.source.value !== QUERY_PACKAGE) return;
|
|
105
|
+
|
|
106
|
+
for (const specifier of declaration.specifiers) {
|
|
107
|
+
const name = importedName(specifier);
|
|
108
|
+
|
|
109
|
+
if (!name) continue;
|
|
110
|
+
|
|
111
|
+
const successor = V2_SUCCESSORS[name] ?? LEGACY_SYMBOLS[name];
|
|
112
|
+
|
|
113
|
+
if (successor) {
|
|
114
|
+
context.report({
|
|
115
|
+
node: specifier,
|
|
116
|
+
messageId: 'successor',
|
|
117
|
+
data: { name, to: successor.to, docs: `${docsBaseUrl}${successor.docs}` },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isV2Symbol(name)) continue;
|
|
124
|
+
|
|
125
|
+
context.report({
|
|
126
|
+
node: specifier,
|
|
127
|
+
messageId: 'legacySystem',
|
|
128
|
+
data: { name, docs: `${docsBaseUrl}/query/migrating-from-v2` },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
module.exports = noLegacyQueryImport;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks the two ends of the same wire: that a `<form>` handles its own submission, and that a
|
|
6
|
+
* submit control reaches a form at all.
|
|
7
|
+
*
|
|
8
|
+
* <form (ngSubmit)="save()"> ✔
|
|
9
|
+
* <form> ✘ pressing Enter in a field does nothing, or reloads the page
|
|
10
|
+
*
|
|
11
|
+
* <form …><button type="submit"> ✔
|
|
12
|
+
* <button type="submit" form="edit"> ✔ associated by id, outside the form's subtree
|
|
13
|
+
* <button type="submit"> ✘ submits nothing
|
|
14
|
+
*
|
|
15
|
+
* A form declaring native submission (`action`, `ngNoForm`, `method="dialog"`) is left alone - it is
|
|
16
|
+
* handled by the platform rather than by a handler.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @param {any} node @param {string} name */
|
|
20
|
+
const attribute = (node, name) => node.attributes?.find(/** @param {any} a */ (a) => a.name === name);
|
|
21
|
+
|
|
22
|
+
/** @param {any} node @param {string} name */
|
|
23
|
+
const hasBinding = (node, name) =>
|
|
24
|
+
node.inputs?.some(/** @param {any} i */ (i) => i.name === name) ||
|
|
25
|
+
node.attributes?.some(/** @param {any} a */ (a) => a.name === name);
|
|
26
|
+
|
|
27
|
+
/** @param {any} node */
|
|
28
|
+
const handlesSubmit = (node) =>
|
|
29
|
+
node.outputs?.some(/** @param {any} o */ (o) => o.name === 'submit' || o.name === 'ngSubmit');
|
|
30
|
+
|
|
31
|
+
/** @param {any} node */
|
|
32
|
+
const submitsNatively = (node) =>
|
|
33
|
+
hasBinding(node, 'action') || hasBinding(node, 'ngNoForm') || attribute(node, 'method')?.value === 'dialog';
|
|
34
|
+
|
|
35
|
+
/** @param {any} node */
|
|
36
|
+
const isSubmitControl = (node) =>
|
|
37
|
+
(node.name === 'button' || node.name === 'input') && attribute(node, 'type')?.value === 'submit';
|
|
38
|
+
|
|
39
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
40
|
+
const requireFormSubmit = {
|
|
41
|
+
meta: {
|
|
42
|
+
type: 'problem',
|
|
43
|
+
docs: {
|
|
44
|
+
description: 'Require a `<form>` to handle its own submission, and a submit control to reach a form.',
|
|
45
|
+
},
|
|
46
|
+
schema: [],
|
|
47
|
+
messages: {
|
|
48
|
+
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.',
|
|
50
|
+
submitOutsideForm:
|
|
51
|
+
'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
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
|
|
56
|
+
|
|
57
|
+
if (!parserServices?.convertNodeSourceSpanToLoc) return {};
|
|
58
|
+
|
|
59
|
+
let formDepth = 0;
|
|
60
|
+
|
|
61
|
+
/** @param {any} node */
|
|
62
|
+
const report = (node, messageId) =>
|
|
63
|
+
context.report({
|
|
64
|
+
loc: parserServices.convertNodeSourceSpanToLoc(node.startSourceSpan ?? node.sourceSpan),
|
|
65
|
+
messageId,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
/** @param {any} node */
|
|
70
|
+
Element(node) {
|
|
71
|
+
if (node.name === 'form') {
|
|
72
|
+
formDepth++;
|
|
73
|
+
|
|
74
|
+
if (!handlesSubmit(node) && !submitsNatively(node)) report(node, 'missingSubmitHandler');
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (formDepth > 0 || !isSubmitControl(node) || hasBinding(node, 'form')) return;
|
|
80
|
+
|
|
81
|
+
report(node, 'submitOutsideForm');
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** @param {any} node */
|
|
85
|
+
'Element:exit'(node) {
|
|
86
|
+
if (node.name === 'form') formDepth--;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = requireFormSubmit;
|