@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.26
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 +18 -0
- package/package.json +2 -1
- package/src/configs/recommended.js +7 -16
- package/src/rules/angular-decorator-property-order.js +24 -7
- package/src/rules/class-constant-property.js +1 -1
- package/src/rules/class-member-order.js +60 -8
- package/src/rules/enforce-routing-view-naming.js +34 -23
- package/src/rules/guard-return-newline.js +4 -7
- package/src/rules/internals/angular-member-visibility.js +24 -2
- package/src/rules/internals/angular-metadata-fix.js +231 -1
- package/src/rules/internals/angular-version.js +50 -0
- package/src/rules/internals/implemented-contract-members.js +26 -11
- package/src/rules/no-angular-router-api.js +86 -33
- package/src/rules/no-angular-seo-services.js +1 -0
- package/src/rules/no-async-await.js +5 -1
- package/src/rules/no-cdk-import.js +32 -7
- package/src/rules/no-direct-dom-manipulation.js +50 -28
- package/src/rules/no-document-cookie.js +51 -8
- package/src/rules/no-dom-query.js +5 -2
- package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
- package/src/rules/no-empty-newlines-between-imports.js +4 -1
- package/src/rules/no-impure-top-level-provider.js +42 -7
- package/src/rules/no-leading-underscore-class-member.js +27 -2
- package/src/rules/no-legacy-angular-decorators.js +11 -6
- package/src/rules/no-legacy-prepare-without-injector.js +57 -12
- package/src/rules/no-legacy-query-import.js +61 -16
- package/src/rules/no-member-alias.js +0 -5
- package/src/rules/no-native-observers.js +89 -24
- package/src/rules/no-pipe-logic.js +1 -2
- package/src/rules/no-readonly-signal.js +1 -5
- package/src/rules/no-redundant-internal.js +5 -2
- package/src/rules/no-redundant-on-push-change-detection.js +3 -48
- package/src/rules/no-rxjs-in-effect.js +7 -5
- package/src/rules/no-screaming-case-local.js +3 -5
- package/src/rules/no-standalone-flag.js +1 -0
- package/src/rules/no-subscribe-with-body.js +30 -1
- package/src/rules/no-template-literal-before-inline-template.js +6 -0
- package/src/rules/no-trivial-return-type.js +18 -1
- package/src/rules/no-unused-class-member.js +1 -1
- package/src/rules/no-window-location.js +60 -22
- package/src/rules/prefer-clone-equal.js +61 -8
- package/src/rules/prefer-concise-angular-host-directives.js +39 -11
- package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
- package/src/rules/prefer-element-dimensions.js +264 -11
- package/src/rules/prefer-match-media.js +17 -3
- package/src/rules/prefer-rxjs-timer.js +26 -4
- package/src/rules/prefer-scroll-state.js +0 -4
- package/src/rules/prefer-static-boolean-properties.js +1 -0
- package/src/rules/prefer-viewport-size.js +153 -16
- package/src/rules/require-dollar-suffix.js +11 -3
- package/src/rules/require-form-submit.js +4 -1
- package/src/rules/require-on-push-change-detection.js +7 -259
- package/src/rules/require-view-encapsulation-none.js +6 -214
- package/src/rules/template-member-accessibility.js +12 -4
|
@@ -1,6 +1,236 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { ANGULAR_CORE, getImportLocalName, resolveIdentifier } = require('./import-resolution');
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ property: string; importName: string; member: string }} TRequiredEnumProperty */
|
|
7
|
+
|
|
8
|
+
const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('estree').Property['key']} key
|
|
12
|
+
*/
|
|
13
|
+
const getPropertyName = (key) => {
|
|
14
|
+
if (key.type === 'Identifier') return key.name;
|
|
15
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string | null} propertyName
|
|
21
|
+
*/
|
|
22
|
+
const getOrderKey = (propertyName) => {
|
|
23
|
+
if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
|
|
24
|
+
if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
|
|
25
|
+
return propertyName;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
30
|
+
* @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
|
|
31
|
+
*/
|
|
32
|
+
const getIndent = (sourceCode, node) => {
|
|
33
|
+
if (!node.loc) return '';
|
|
34
|
+
|
|
35
|
+
const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
36
|
+
return line.slice(0, node.loc.start.column);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
41
|
+
* @param {any} property
|
|
42
|
+
*/
|
|
43
|
+
const hasTrailingComma = (sourceCode, property) => {
|
|
44
|
+
const tokenAfter = sourceCode.getTokenAfter(property);
|
|
45
|
+
return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
50
|
+
* @param {string} importName
|
|
51
|
+
*/
|
|
52
|
+
const findAngularCoreImport = (sourceCode, importName) => {
|
|
53
|
+
const imports = sourceCode.ast.body.filter(
|
|
54
|
+
(node) =>
|
|
55
|
+
node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
imports.find((node) =>
|
|
60
|
+
node.specifiers.some(
|
|
61
|
+
(specifier) =>
|
|
62
|
+
specifier.type === 'ImportSpecifier' &&
|
|
63
|
+
specifier.imported.type === 'Identifier' &&
|
|
64
|
+
specifier.imported.name === importName,
|
|
65
|
+
),
|
|
66
|
+
) ??
|
|
67
|
+
imports.find((node) => node.importKind !== 'type') ??
|
|
68
|
+
null
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
74
|
+
* @param {any} importNode
|
|
75
|
+
* @param {string} importName
|
|
76
|
+
*/
|
|
77
|
+
const buildAngularCoreImportFix = (sourceCode, importNode, importName) => {
|
|
78
|
+
const specifiers = importNode.specifiers ?? [];
|
|
79
|
+
const hasImport = specifiers.some(
|
|
80
|
+
(specifier) =>
|
|
81
|
+
specifier.type === 'ImportSpecifier' &&
|
|
82
|
+
specifier.imported.type === 'Identifier' &&
|
|
83
|
+
specifier.imported.name === importName,
|
|
84
|
+
);
|
|
85
|
+
if (hasImport) return null;
|
|
86
|
+
|
|
87
|
+
const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
|
|
88
|
+
const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
|
|
89
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
|
|
90
|
+
|
|
91
|
+
if (namespaceSpecifier) {
|
|
92
|
+
return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ${importName} } from '@angular/core';`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lastNamed = namedSpecifiers.at(-1);
|
|
96
|
+
|
|
97
|
+
if (lastNamed) {
|
|
98
|
+
const isMultiline = importNode.loc.start.line !== importNode.loc.end.line;
|
|
99
|
+
const indent = /^\s*/.exec(sourceCode.lines[lastNamed.loc.start.line - 1] ?? '')?.[0] ?? '';
|
|
100
|
+
const startsLine = sourceCode.lines[lastNamed.loc.start.line - 1].trim().startsWith(sourceCode.getText(lastNamed));
|
|
101
|
+
|
|
102
|
+
return (fixer) =>
|
|
103
|
+
fixer.insertTextAfter(lastNamed, isMultiline && startsLine ? `,\n${indent}${importName}` : `, ${importName}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const importParts = [];
|
|
107
|
+
if (defaultSpecifier) {
|
|
108
|
+
importParts.push(sourceCode.getText(defaultSpecifier));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
|
|
112
|
+
importParts.push(`{ ${[...namedTexts, importName].join(', ')} }`);
|
|
113
|
+
|
|
114
|
+
return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
119
|
+
* @param {string} importName
|
|
120
|
+
*/
|
|
121
|
+
const buildMissingAngularCoreImportFix = (sourceCode, importName) => {
|
|
122
|
+
const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
123
|
+
const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
|
|
124
|
+
|
|
125
|
+
if (lastImport) {
|
|
126
|
+
return (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ${importName} } from '@angular/core';`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const firstNode = sourceCode.ast.body[0];
|
|
130
|
+
const lastComment = firstNode ? sourceCode.getCommentsBefore(firstNode).at(-1) : null;
|
|
131
|
+
|
|
132
|
+
return lastComment
|
|
133
|
+
? (fixer) => fixer.insertTextAfter(lastComment, `\nimport { ${importName} } from '@angular/core';\n`)
|
|
134
|
+
: (fixer) =>
|
|
135
|
+
fixer.replaceTextRange([0, firstNode?.range[0] ?? 0], `import { ${importName} } from '@angular/core';\n\n`);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {any} metadata
|
|
140
|
+
* @param {string} propertyKey
|
|
141
|
+
*/
|
|
142
|
+
const getInsertionTarget = (metadata, propertyKey) => {
|
|
143
|
+
if (metadata.properties.some((property) => property.type === 'SpreadElement')) return null;
|
|
144
|
+
|
|
145
|
+
const anchorIndex = COMPONENT_ORDER.indexOf(propertyKey);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
metadata.properties.find((property) => {
|
|
149
|
+
if (property.type !== 'Property') return false;
|
|
150
|
+
|
|
151
|
+
const propertyName = getOrderKey(getPropertyName(property.key));
|
|
152
|
+
const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
|
|
153
|
+
return propertyIndex > anchorIndex || propertyIndex === -1;
|
|
154
|
+
}) ?? null
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
160
|
+
* @param {any} metadata
|
|
161
|
+
* @param {TRequiredEnumProperty} spec
|
|
162
|
+
*/
|
|
163
|
+
const buildMetadataFix = (sourceCode, metadata, spec) => {
|
|
164
|
+
const propertyText = `${spec.property}: ${getImportLocalName(sourceCode, ANGULAR_CORE, spec.importName) ?? spec.importName}.${spec.member}`;
|
|
165
|
+
const properties = metadata.properties;
|
|
166
|
+
const insertionTarget = getInsertionTarget(metadata, spec.property);
|
|
167
|
+
const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
|
|
168
|
+
|
|
169
|
+
if (properties.length === 0) {
|
|
170
|
+
return (fixer) => fixer.replaceText(metadata, `{ ${propertyText} }`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (insertionTarget) {
|
|
174
|
+
if (isMultiline) {
|
|
175
|
+
const propertyIndent = getIndent(sourceCode, insertionTarget);
|
|
176
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${propertyText},\n${propertyIndent}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${propertyText}, `);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const lastProperty = properties[properties.length - 1];
|
|
183
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
184
|
+
if (!lastProperty || !closingBrace) return null;
|
|
185
|
+
|
|
186
|
+
if (isMultiline) {
|
|
187
|
+
const closingIndent = getIndent(sourceCode, closingBrace);
|
|
188
|
+
const propertyIndent = getIndent(sourceCode, lastProperty);
|
|
189
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
190
|
+
const rangeStart =
|
|
191
|
+
separator === ''
|
|
192
|
+
? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
|
|
193
|
+
: lastProperty.range[1];
|
|
194
|
+
|
|
195
|
+
return (fixer) =>
|
|
196
|
+
fixer.replaceTextRange(
|
|
197
|
+
[rangeStart, closingBrace.range[0]],
|
|
198
|
+
`${separator}\n${propertyIndent}${propertyText}\n${closingIndent}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
203
|
+
return (fixer) =>
|
|
204
|
+
fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${propertyText} `);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
209
|
+
* @param {any} metadata
|
|
210
|
+
* @param {any} valueNode
|
|
211
|
+
* @param {TRequiredEnumProperty} spec
|
|
212
|
+
*/
|
|
213
|
+
const buildRequiredEnumFix = (sourceCode, metadata, valueNode, spec) => {
|
|
214
|
+
const valueFix =
|
|
215
|
+
valueNode &&
|
|
216
|
+
((/** @type {import('eslint').Rule.RuleFixer} */ fixer) => fixer.replaceText(valueNode.property, spec.member));
|
|
217
|
+
if (valueNode && valueNode.object.type !== 'Identifier') return valueFix;
|
|
218
|
+
if (valueNode && resolveIdentifier(sourceCode, valueNode.object)?.source) return valueFix;
|
|
219
|
+
|
|
220
|
+
const fixes = [];
|
|
221
|
+
const angularCoreImport = findAngularCoreImport(sourceCode, spec.importName);
|
|
222
|
+
const importFix = angularCoreImport
|
|
223
|
+
? buildAngularCoreImportFix(sourceCode, angularCoreImport, spec.importName)
|
|
224
|
+
: buildMissingAngularCoreImportFix(sourceCode, spec.importName);
|
|
225
|
+
|
|
226
|
+
if (importFix) fixes.push(importFix);
|
|
227
|
+
|
|
228
|
+
const metadataFix = valueFix ?? buildMetadataFix(sourceCode, metadata, spec);
|
|
229
|
+
if (metadataFix) fixes.push(metadataFix);
|
|
230
|
+
|
|
231
|
+
return (/** @type {import('eslint').Rule.RuleFixer} */ fixer) => fixes.map((applyFix) => applyFix(fixer));
|
|
232
|
+
};
|
|
233
|
+
|
|
4
234
|
/**
|
|
5
235
|
* @param {import('eslint').SourceCode} sourceCode
|
|
6
236
|
* @param {any} metadata
|
|
@@ -19,4 +249,4 @@ const getMetadataEntryRemovalRange = (sourceCode, metadata, property) => {
|
|
|
19
249
|
return [entries[index - 1].range[1], property.range[1]];
|
|
20
250
|
};
|
|
21
251
|
|
|
22
|
-
module.exports = { getMetadataEntryRemovalRange };
|
|
252
|
+
module.exports = { buildRequiredEnumFix, getMetadataEntryRemovalRange, getPropertyName };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/** @type {Map<string, number | null>} */
|
|
7
|
+
const cachedByDirectory = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} directory
|
|
11
|
+
* @returns {number | null}
|
|
12
|
+
*/
|
|
13
|
+
const detectAngularMajor = (directory) => {
|
|
14
|
+
const cached = cachedByDirectory.get(directory);
|
|
15
|
+
if (cached !== undefined) return cached;
|
|
16
|
+
|
|
17
|
+
let result;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const pkg = require(require.resolve('@angular/core/package.json', { paths: [directory, __dirname] }));
|
|
21
|
+
const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
|
|
22
|
+
result = Number.isNaN(major) ? null : major;
|
|
23
|
+
} catch {
|
|
24
|
+
result = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cachedByDirectory.set(directory, result);
|
|
28
|
+
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Angular major installed next to the linted file (falling back to the plugin's own resolution).
|
|
34
|
+
* `settings.ethlete.angularMajor` wins over detection.
|
|
35
|
+
*
|
|
36
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
37
|
+
* @returns {number | null}
|
|
38
|
+
*/
|
|
39
|
+
const getAngularMajor = (context) => {
|
|
40
|
+
const settings = /** @type {any} */ (context.settings);
|
|
41
|
+
const override = settings?.ethlete?.angularMajor;
|
|
42
|
+
if (typeof override === 'number') return override;
|
|
43
|
+
|
|
44
|
+
const filename = context.filename;
|
|
45
|
+
const directory = filename && path.isAbsolute(filename) ? path.dirname(filename) : process.cwd();
|
|
46
|
+
|
|
47
|
+
return detectAngularMajor(directory);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
module.exports = { getAngularMajor };
|
|
@@ -5,9 +5,21 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const ts = require('typescript');
|
|
7
7
|
|
|
8
|
-
/** @type {Map<string, import('typescript').SourceFile | null>} */
|
|
8
|
+
/** @type {Map<string, { stamp: string, sourceFile: import('typescript').SourceFile | null }>} */
|
|
9
9
|
const SOURCE_FILE_CACHE = new Map();
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} filePath
|
|
13
|
+
*/
|
|
14
|
+
const getFileStamp = (filePath) => {
|
|
15
|
+
try {
|
|
16
|
+
const stats = fs.statSync(filePath);
|
|
17
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
18
|
+
} catch {
|
|
19
|
+
return 'missing';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
11
23
|
/**
|
|
12
24
|
* @param {string} filePath
|
|
13
25
|
*/
|
|
@@ -31,19 +43,18 @@ const getSourceFile = ({ filePath, text = null }) => {
|
|
|
31
43
|
return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
32
44
|
}
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
const stamp = getFileStamp(filePath);
|
|
47
|
+
const cached = SOURCE_FILE_CACHE.get(filePath);
|
|
48
|
+
|
|
49
|
+
if (cached && cached.stamp === stamp) {
|
|
50
|
+
return cached.sourceFile;
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
const fileText = readFileIfExists(filePath);
|
|
54
|
+
const sourceFile =
|
|
55
|
+
fileText === null ? null : ts.createSourceFile(filePath, fileText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
SOURCE_FILE_CACHE.set(filePath, null);
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const sourceFile = ts.createSourceFile(filePath, fileText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
46
|
-
SOURCE_FILE_CACHE.set(filePath, sourceFile);
|
|
57
|
+
SOURCE_FILE_CACHE.set(filePath, { stamp, sourceFile });
|
|
47
58
|
return sourceFile;
|
|
48
59
|
};
|
|
49
60
|
|
|
@@ -257,6 +268,10 @@ const collectMemberNamesForTypeByName = ({
|
|
|
257
268
|
* @param {{ classNode: any, context: import('eslint').Rule.RuleContext }} config
|
|
258
269
|
*/
|
|
259
270
|
const getImplementedContractMemberNames = ({ classNode, context }) => {
|
|
271
|
+
if (!classNode.implements?.length) {
|
|
272
|
+
return new Set();
|
|
273
|
+
}
|
|
274
|
+
|
|
260
275
|
const filename = context.physicalFilename || context.filename;
|
|
261
276
|
if (!filename || filename === '<input>' || filename === '<text>') {
|
|
262
277
|
return new Set();
|
|
@@ -270,7 +285,7 @@ const getImplementedContractMemberNames = ({ classNode, context }) => {
|
|
|
270
285
|
/** @type {Set<string>} */
|
|
271
286
|
const memberNames = new Set();
|
|
272
287
|
|
|
273
|
-
for (const implementedType of classNode.implements
|
|
288
|
+
for (const implementedType of classNode.implements) {
|
|
274
289
|
if (!implementedType.expression || implementedType.expression.type !== 'Identifier') continue;
|
|
275
290
|
|
|
276
291
|
const typeMemberNames = collectMemberNamesForTypeByName({
|
|
@@ -47,7 +47,6 @@ const ROUTER_STATE_PROPS = new Map([
|
|
|
47
47
|
['url', "injectUrl() from '@ethlete/core'"],
|
|
48
48
|
['events', "injectRouterEvent() from '@ethlete/core'"],
|
|
49
49
|
['routerState', "injectRouterState() from '@ethlete/core'"],
|
|
50
|
-
['snapshot', "injectRouterState() from '@ethlete/core'"],
|
|
51
50
|
['lastSuccessfulNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
52
51
|
['currentNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
53
52
|
]);
|
|
@@ -81,47 +80,103 @@ const noAngularRouterApi = {
|
|
|
81
80
|
isImportedAs(sourceCode, call.callee, 'inject') &&
|
|
82
81
|
isImportedAs(sourceCode, call.arguments[0], token, ANGULAR_ROUTER);
|
|
83
82
|
|
|
83
|
+
/** @type {WeakMap<any, Set<string>>} */
|
|
84
|
+
const classRouterMembers = new WeakMap();
|
|
85
|
+
|
|
84
86
|
/**
|
|
85
|
-
*
|
|
86
|
-
* We track identifiers whose initializer is inject(Router) so we can
|
|
87
|
-
* detect property access on them anywhere in the file.
|
|
88
|
-
* @type {Set<string>}
|
|
87
|
+
* @param {any} classBody
|
|
89
88
|
*/
|
|
90
|
-
const
|
|
89
|
+
const getClassRouterMembers = (classBody) => {
|
|
90
|
+
let members = classRouterMembers.get(classBody);
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
if (!members) {
|
|
93
|
+
members = new Set();
|
|
94
|
+
|
|
95
|
+
for (const member of classBody.body) {
|
|
96
|
+
if (member.type === 'PropertyDefinition' && member.key.type === 'Identifier' && !member.computed) {
|
|
97
|
+
if (isInjectOf(member.value, 'Router')) members.add(member.key.name);
|
|
98
|
+
}
|
|
98
99
|
}
|
|
99
|
-
},
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
classRouterMembers.set(classBody, members);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return members;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {any} thisExpression
|
|
109
|
+
*/
|
|
110
|
+
const getThisClassBody = (thisExpression) => {
|
|
111
|
+
let current = thisExpression.parent;
|
|
112
|
+
|
|
113
|
+
while (current) {
|
|
114
|
+
if (current.type === 'ClassBody') return current;
|
|
115
|
+
|
|
116
|
+
if (current.type === 'FunctionExpression' || current.type === 'FunctionDeclaration') {
|
|
117
|
+
const parent = current.parent;
|
|
118
|
+
const isMember = parent?.type === 'MethodDefinition' || parent?.type === 'PropertyDefinition';
|
|
119
|
+
if (!isMember) return null;
|
|
105
120
|
}
|
|
106
|
-
},
|
|
107
121
|
|
|
108
|
-
|
|
122
|
+
current = current.parent;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {any} identifier
|
|
130
|
+
*/
|
|
131
|
+
const isRouterVariable = (identifier) => {
|
|
132
|
+
/** @type {import('eslint').Scope.Scope | null} */
|
|
133
|
+
let scope = sourceCode.getScope(identifier);
|
|
134
|
+
|
|
135
|
+
while (scope) {
|
|
136
|
+
const variable = scope.set.get(identifier.name);
|
|
137
|
+
|
|
138
|
+
if (variable) {
|
|
139
|
+
const definition = /** @type {any} */ (variable.defs[0]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
definition?.type === 'Variable' &&
|
|
143
|
+
definition.node.id.type === 'Identifier' &&
|
|
144
|
+
isInjectOf(definition.node.init, 'Router')
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
scope = scope.upper;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return false;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {any} object
|
|
156
|
+
*/
|
|
157
|
+
const isRouterReference = (object) => {
|
|
158
|
+
if (object.type === 'Identifier') return isRouterVariable(object);
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
object.type !== 'MemberExpression' ||
|
|
162
|
+
object.computed ||
|
|
163
|
+
object.object.type !== 'ThisExpression' ||
|
|
164
|
+
object.property.type !== 'Identifier'
|
|
165
|
+
) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const classBody = getThisClassBody(object.object);
|
|
170
|
+
|
|
171
|
+
return !!classBody && getClassRouterMembers(classBody).has(object.property.name);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
109
175
|
MemberExpression(node) {
|
|
110
176
|
if (node.property.type !== 'Identifier') return;
|
|
111
177
|
const prop = node.property.name;
|
|
112
178
|
if (!ROUTER_STATE_PROPS.has(prop)) return;
|
|
113
|
-
|
|
114
|
-
// Check object is a known router binding (direct: router.url)
|
|
115
|
-
// or a this.router.url / self.router.url pattern
|
|
116
|
-
const obj = node.object;
|
|
117
|
-
const isDirectBinding = obj.type === 'Identifier' && routerBindings.has(obj.name);
|
|
118
|
-
const isThisBinding =
|
|
119
|
-
obj.type === 'MemberExpression' &&
|
|
120
|
-
obj.object.type === 'ThisExpression' &&
|
|
121
|
-
obj.property.type === 'Identifier' &&
|
|
122
|
-
routerBindings.has(obj.property.name);
|
|
123
|
-
|
|
124
|
-
if (!isDirectBinding && !isThisBinding) return;
|
|
179
|
+
if (!isRouterReference(node.object)) return;
|
|
125
180
|
|
|
126
181
|
context.report({
|
|
127
182
|
node,
|
|
@@ -130,7 +185,6 @@ const noAngularRouterApi = {
|
|
|
130
185
|
});
|
|
131
186
|
},
|
|
132
187
|
|
|
133
|
-
// ── ActivatedRoute import ───────────────────────────────────────────────
|
|
134
188
|
[MODULE_REFERENCE_SELECTOR](node) {
|
|
135
189
|
const reference = getModuleReference(node);
|
|
136
190
|
if (reference?.source !== ANGULAR_ROUTER) return;
|
|
@@ -139,7 +193,6 @@ const noAngularRouterApi = {
|
|
|
139
193
|
}
|
|
140
194
|
},
|
|
141
195
|
|
|
142
|
-
// ── inject(ActivatedRoute) ──────────────────────────────────────────────
|
|
143
196
|
CallExpression(node) {
|
|
144
197
|
if (isInjectOf(node, 'ActivatedRoute')) {
|
|
145
198
|
context.report({ node, messageId: 'noActivatedRoute' });
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* }
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
/** Storybook's play function is called by the test runner and has to be a promise. */
|
|
22
|
+
/** Storybook's play function is called by the test runner and has to be a promise; only exempt in story files. */
|
|
23
23
|
const isStorybookPlayFunction = (node) => {
|
|
24
24
|
const { parent } = node;
|
|
25
25
|
|
|
@@ -49,7 +49,11 @@ const noAsyncAwait = {
|
|
|
49
49
|
schema: [],
|
|
50
50
|
},
|
|
51
51
|
create(context) {
|
|
52
|
+
const isStoryFile = /\.stories\.[cm]?[jt]sx?$/.test(context.filename);
|
|
53
|
+
|
|
52
54
|
const isExempt = (node) => {
|
|
55
|
+
if (!isStoryFile) return false;
|
|
56
|
+
|
|
53
57
|
for (let current = node; current; current = current.parent) {
|
|
54
58
|
if (isStorybookPlayFunction(current)) return true;
|
|
55
59
|
}
|
|
@@ -26,17 +26,33 @@ const CDK_PACKAGE = '@ethlete/cdk';
|
|
|
26
26
|
const MIGRATION_MAP = '@ethlete/cdk/migration-map.json';
|
|
27
27
|
const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
|
|
28
28
|
|
|
29
|
-
/** @
|
|
29
|
+
/** @typedef {Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }>} TMigrationMap */
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, { mtimeMs: number | null, map: TMigrationMap }>} */
|
|
30
32
|
const mapCache = new Map();
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} path
|
|
36
|
+
*/
|
|
37
|
+
const getMtimeMs = (path) => {
|
|
38
|
+
try {
|
|
39
|
+
return fs.statSync(path).mtimeMs;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* @param {string | undefined} mapPath
|
|
34
47
|
* @param {string} cwd
|
|
48
|
+
* @returns {TMigrationMap | null}
|
|
35
49
|
*/
|
|
36
50
|
const loadMigrationMap = (mapPath, cwd) => {
|
|
37
|
-
const cacheKey = mapPath ? path.resolve(cwd, mapPath) : MIGRATION_MAP
|
|
51
|
+
const cacheKey = mapPath ? path.resolve(cwd, mapPath) : `${cwd}:${MIGRATION_MAP}`;
|
|
52
|
+
const mtimeMs = mapPath ? getMtimeMs(cacheKey) : null;
|
|
53
|
+
const cached = mapCache.get(cacheKey);
|
|
38
54
|
|
|
39
|
-
if (
|
|
55
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.map;
|
|
40
56
|
|
|
41
57
|
let map;
|
|
42
58
|
|
|
@@ -45,14 +61,23 @@ const loadMigrationMap = (mapPath, cwd) => {
|
|
|
45
61
|
? JSON.parse(fs.readFileSync(cacheKey, 'utf8'))
|
|
46
62
|
: createRequire(path.join(cwd, 'noop.js'))(MIGRATION_MAP);
|
|
47
63
|
} catch {
|
|
48
|
-
|
|
64
|
+
return null;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
mapCache.set(cacheKey, map);
|
|
67
|
+
mapCache.set(cacheKey, { mtimeMs, map });
|
|
52
68
|
|
|
53
69
|
return map;
|
|
54
70
|
};
|
|
55
71
|
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} note
|
|
74
|
+
*/
|
|
75
|
+
const formatNote = (note) => {
|
|
76
|
+
const sentence = note.charAt(0).toUpperCase() + note.slice(1);
|
|
77
|
+
|
|
78
|
+
return /[.!?]$/u.test(sentence) ? sentence : `${sentence}.`;
|
|
79
|
+
};
|
|
80
|
+
|
|
56
81
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
57
82
|
const noCdkImport = {
|
|
58
83
|
meta: {
|
|
@@ -86,7 +111,7 @@ const noCdkImport = {
|
|
|
86
111
|
const map = loadMigrationMap(options.migrationMapPath, context.cwd);
|
|
87
112
|
|
|
88
113
|
/** @param {string} name */
|
|
89
|
-
const entryFor = (name) => map
|
|
114
|
+
const entryFor = (name) => (map && Object.hasOwn(map, name) ? map[name] : null);
|
|
90
115
|
|
|
91
116
|
return {
|
|
92
117
|
[MODULE_REFERENCE_SELECTOR](node) {
|
|
@@ -135,7 +160,7 @@ const noCdkImport = {
|
|
|
135
160
|
to: entry.to,
|
|
136
161
|
package: entry.package ?? '@ethlete/components',
|
|
137
162
|
docs: entry.docs ? `${docsBaseUrl}${entry.docs}` : migrationDocs,
|
|
138
|
-
note: entry.note ? ` ${entry.note}
|
|
163
|
+
note: entry.note ? ` ${formatNote(entry.note)}` : '',
|
|
139
164
|
},
|
|
140
165
|
});
|
|
141
166
|
}
|