@ethlete/eslint-plugin 1.0.0-next.23 → 1.0.0-next.25
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 +26 -0
- package/migrations.json +12 -0
- package/package.json +13 -3
- package/src/configs/recommended.js +33 -11
- package/src/generators/generators.json +10 -0
- package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
- package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
- package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
- package/src/index.js +10 -2
- package/src/rules/angular-decorator-property-order.js +20 -20
- package/src/rules/class-constant-property.js +8 -14
- package/src/rules/class-member-order.js +77 -46
- package/src/rules/consistent-type-definitions.js +60 -0
- package/src/rules/inject-member-accessibility.js +5 -3
- package/src/rules/internals/angular-io.js +12 -13
- package/src/rules/internals/angular-member-visibility.js +11 -7
- package/src/rules/internals/import-resolution.js +191 -0
- package/src/rules/no-angular-router-api.js +23 -29
- package/src/rules/no-angular-seo-services.js +14 -9
- package/src/rules/no-cdk-import.js +7 -14
- package/src/rules/no-csp-unsafe.js +362 -0
- package/src/rules/no-document-cookie.js +3 -0
- package/src/rules/no-effect-cleanup-return.js +7 -3
- package/src/rules/no-empty-angular-metadata-arrays.js +3 -17
- package/src/rules/no-inject-chain.js +3 -1
- package/src/rules/no-leading-underscore-class-member.js +26 -3
- package/src/rules/no-legacy-angular-decorators.js +9 -21
- package/src/rules/no-legacy-query-import.js +6 -15
- package/src/rules/no-locale-id.js +4 -4
- package/src/rules/no-member-alias.js +11 -7
- package/src/rules/no-native-html-input-name.js +1 -1
- package/src/rules/no-pipe-logic.js +10 -17
- package/src/rules/no-redundant-on-push-change-detection.js +9 -23
- package/src/rules/no-rxjs-in-effect.js +12 -8
- package/src/rules/no-screaming-case-local.js +1 -0
- package/src/rules/no-standalone-flag.js +3 -17
- package/src/rules/no-subscribe-in-pipe.js +3 -2
- package/src/rules/no-template-literal-before-inline-template.js +3 -14
- package/src/rules/no-trivial-return-type.js +1 -0
- package/src/rules/no-trivial-wrapper-method.js +10 -16
- package/src/rules/no-typed-injected-element-ref.js +14 -17
- package/src/rules/no-unused-class-member.js +6 -9
- package/src/rules/prefer-clone-equal.js +8 -6
- package/src/rules/prefer-concise-angular-host-directives.js +23 -29
- package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
- package/src/rules/prefer-element-dimensions.js +9 -10
- package/src/rules/prefer-linked-signal.js +42 -41
- package/src/rules/prefer-match-media.js +10 -14
- package/src/rules/prefer-present-tense-output.js +1 -1
- package/src/rules/prefer-rxjs-timer.js +3 -1
- package/src/rules/require-dollar-suffix.js +7 -4
- package/src/rules/require-on-push-change-detection.js +17 -25
- package/src/rules/require-view-encapsulation-none.js +17 -25
- package/src/rules/take-until-destroyed-last.js +64 -0
- package/src/rules/template-member-accessibility.js +8 -6
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { ANGULAR_CORE, getImportedName } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/** @typedef {'inject' | 'input' | 'output' | 'query' | 'property' | 'constructor' | 'method' | 'private-method'} TMemberGroup */
|
|
5
7
|
/** @typedef {{ dependencies: Set<string>; group: TMemberGroup; groupIndex: number; name: string; node: any; originalIndex: number }} TMemberEntry */
|
|
6
|
-
/** @typedef {TMemberEntry & { segmentText: string }} TSegmentEntry */
|
|
7
8
|
|
|
8
9
|
const INPUT_APIS = new Set(['input', 'model']);
|
|
9
10
|
const OUTPUT_APIS = new Set(['output', 'outputFromObservable']);
|
|
10
11
|
const QUERY_APIS = new Set(['viewChild', 'viewChildren', 'contentChild', 'contentChildren']);
|
|
12
|
+
const ANGULAR_API_SOURCES = [ANGULAR_CORE, '@angular/core/rxjs-interop'];
|
|
11
13
|
|
|
12
14
|
/** @type {TMemberGroup[]} */
|
|
13
15
|
const MEMBER_GROUP_ORDER = [
|
|
@@ -71,49 +73,70 @@ const getCallRootName = (value) => {
|
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
/**
|
|
76
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
77
|
+
* @param {any} value
|
|
78
|
+
*/
|
|
79
|
+
const getAngularCallName = (sourceCode, value) => {
|
|
80
|
+
if (!value || value.type !== 'CallExpression') return null;
|
|
81
|
+
|
|
82
|
+
const { callee } = value;
|
|
83
|
+
const isRequired =
|
|
84
|
+
callee.type === 'MemberExpression' &&
|
|
85
|
+
!callee.computed &&
|
|
86
|
+
callee.property.type === 'Identifier' &&
|
|
87
|
+
callee.property.name === 'required';
|
|
88
|
+
|
|
89
|
+
return getImportedName(sourceCode, isRequired ? callee.object : callee, ANGULAR_API_SOURCES);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
74
94
|
* @param {any} node
|
|
75
95
|
* @param {Set<string>} apiNames
|
|
76
96
|
*/
|
|
77
|
-
const isPropertyInitializedWith = (node, apiNames) => {
|
|
97
|
+
const isPropertyInitializedWith = (sourceCode, node, apiNames) => {
|
|
78
98
|
if (node.type !== 'PropertyDefinition') return false;
|
|
79
99
|
|
|
80
|
-
const apiName =
|
|
100
|
+
const apiName = getAngularCallName(sourceCode, node.value);
|
|
81
101
|
return apiName !== null && apiNames.has(apiName);
|
|
82
102
|
};
|
|
83
103
|
|
|
84
104
|
const INJECT_HELPER_NAME_PATTERN = /^inject[A-Z]/;
|
|
85
105
|
|
|
86
106
|
/**
|
|
107
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
87
108
|
* @param {any} node
|
|
88
109
|
*/
|
|
89
|
-
const isPropertyInitializedWithInject = (node) => {
|
|
110
|
+
const isPropertyInitializedWithInject = (sourceCode, node) => {
|
|
90
111
|
if (node.type !== 'PropertyDefinition') return false;
|
|
112
|
+
if (getAngularCallName(sourceCode, node.value) === 'inject') return true;
|
|
91
113
|
|
|
92
|
-
const
|
|
93
|
-
return
|
|
114
|
+
const rootName = getCallRootName(node.value);
|
|
115
|
+
return rootName !== null && INJECT_HELPER_NAME_PATTERN.test(rootName);
|
|
94
116
|
};
|
|
95
117
|
|
|
96
118
|
/**
|
|
119
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
97
120
|
* @param {any} node
|
|
98
121
|
* @returns {TMemberGroup | null}
|
|
99
122
|
*/
|
|
100
|
-
const getMemberGroup = (node) => {
|
|
123
|
+
const getMemberGroup = (sourceCode, node) => {
|
|
101
124
|
if (node.static) return null;
|
|
102
125
|
|
|
103
126
|
if (node.type === 'PropertyDefinition') {
|
|
104
|
-
if (isPropertyInitializedWithInject(node)) {
|
|
127
|
+
if (isPropertyInitializedWithInject(sourceCode, node)) {
|
|
105
128
|
return 'inject';
|
|
106
129
|
}
|
|
107
130
|
|
|
108
|
-
if (isPropertyInitializedWith(node, INPUT_APIS)) {
|
|
131
|
+
if (isPropertyInitializedWith(sourceCode, node, INPUT_APIS)) {
|
|
109
132
|
return 'input';
|
|
110
133
|
}
|
|
111
134
|
|
|
112
|
-
if (isPropertyInitializedWith(node, OUTPUT_APIS)) {
|
|
135
|
+
if (isPropertyInitializedWith(sourceCode, node, OUTPUT_APIS)) {
|
|
113
136
|
return 'output';
|
|
114
137
|
}
|
|
115
138
|
|
|
116
|
-
if (isPropertyInitializedWith(node, QUERY_APIS)) {
|
|
139
|
+
if (isPropertyInitializedWith(sourceCode, node, QUERY_APIS)) {
|
|
117
140
|
return 'query';
|
|
118
141
|
}
|
|
119
142
|
|
|
@@ -188,12 +211,13 @@ const getInitializerDependencies = (node) => {
|
|
|
188
211
|
};
|
|
189
212
|
|
|
190
213
|
/**
|
|
214
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
191
215
|
* @param {any} node
|
|
192
216
|
* @param {number} originalIndex
|
|
193
217
|
* @returns {TMemberEntry | null}
|
|
194
218
|
*/
|
|
195
|
-
const toMemberEntry = (node, originalIndex) => {
|
|
196
|
-
const group = getMemberGroup(node);
|
|
219
|
+
const toMemberEntry = (sourceCode, node, originalIndex) => {
|
|
220
|
+
const group = getMemberGroup(sourceCode, node);
|
|
197
221
|
const name = getMemberName(node);
|
|
198
222
|
|
|
199
223
|
if (group === null || name === null) {
|
|
@@ -273,12 +297,24 @@ const sortMembers = (members) => {
|
|
|
273
297
|
return sortedMembers;
|
|
274
298
|
};
|
|
275
299
|
|
|
300
|
+
/**
|
|
301
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
302
|
+
* @param {any} node
|
|
303
|
+
*/
|
|
304
|
+
const getMemberEnd = (sourceCode, node) => {
|
|
305
|
+
const trailingComments = sourceCode
|
|
306
|
+
.getCommentsAfter(node)
|
|
307
|
+
.filter((comment) => comment.loc?.start.line === node.loc.end.line);
|
|
308
|
+
const lastComment = trailingComments.at(-1);
|
|
309
|
+
|
|
310
|
+
return lastComment?.range ? lastComment.range[1] : node.range[1];
|
|
311
|
+
};
|
|
312
|
+
|
|
276
313
|
/**
|
|
277
314
|
* @param {import('eslint').SourceCode} sourceCode
|
|
278
315
|
* @param {any} classBodyNode
|
|
279
|
-
* @param {TMemberEntry[]} members
|
|
280
316
|
*/
|
|
281
|
-
const
|
|
317
|
+
const getSegments = (sourceCode, classBodyNode) => {
|
|
282
318
|
const openingBrace = sourceCode.getFirstToken(classBodyNode);
|
|
283
319
|
const closingBrace = sourceCode.getLastToken(classBodyNode);
|
|
284
320
|
|
|
@@ -288,22 +324,23 @@ const getSegmentEntries = (sourceCode, classBodyNode, members) => {
|
|
|
288
324
|
|
|
289
325
|
let segmentStart = openingBrace.range[1];
|
|
290
326
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
327
|
+
/** @type {string[]} */
|
|
328
|
+
const segmentTexts = classBodyNode.body.map(
|
|
329
|
+
/** @param {any} node */ (node) => {
|
|
330
|
+
const segmentEnd = getMemberEnd(sourceCode, node);
|
|
331
|
+
const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
|
|
332
|
+
segmentStart = segmentEnd;
|
|
294
333
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
};
|
|
299
|
-
});
|
|
334
|
+
return segmentText;
|
|
335
|
+
},
|
|
336
|
+
);
|
|
300
337
|
|
|
301
338
|
const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
|
|
302
339
|
|
|
303
340
|
return {
|
|
304
341
|
closingBrace,
|
|
305
342
|
openingBrace,
|
|
306
|
-
|
|
343
|
+
segmentTexts,
|
|
307
344
|
suffix,
|
|
308
345
|
};
|
|
309
346
|
};
|
|
@@ -314,43 +351,37 @@ const getSegmentEntries = (sourceCode, classBodyNode, members) => {
|
|
|
314
351
|
* @param {TMemberEntry[]} members
|
|
315
352
|
*/
|
|
316
353
|
const buildClassOrderFix = (sourceCode, classBodyNode, members) => {
|
|
317
|
-
if (classBodyNode.body.length !== members.length) {
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
354
|
const sortedMembers = sortMembers(members);
|
|
322
355
|
|
|
323
356
|
if (!sortedMembers) {
|
|
324
357
|
return null;
|
|
325
358
|
}
|
|
326
359
|
|
|
327
|
-
const
|
|
360
|
+
const isAlreadySorted = sortedMembers.every((member, index) => member === members[index]);
|
|
328
361
|
|
|
329
|
-
if (
|
|
362
|
+
if (isAlreadySorted) {
|
|
330
363
|
return null;
|
|
331
364
|
}
|
|
332
365
|
|
|
333
|
-
const
|
|
334
|
-
const segmentEntriesByOriginalIndex = new Map(
|
|
335
|
-
segmentEntries.map(
|
|
336
|
-
/** @param {TSegmentEntry} segmentEntry */ (segmentEntry) => [segmentEntry.originalIndex, segmentEntry],
|
|
337
|
-
),
|
|
338
|
-
);
|
|
339
|
-
const sortedSegmentEntries = sortedMembers.map((member) => segmentEntriesByOriginalIndex.get(member.originalIndex));
|
|
366
|
+
const segments = getSegments(sourceCode, classBodyNode);
|
|
340
367
|
|
|
341
|
-
if (
|
|
368
|
+
if (!segments) {
|
|
342
369
|
return null;
|
|
343
370
|
}
|
|
344
371
|
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
372
|
+
const { closingBrace, openingBrace, segmentTexts, suffix } = segments;
|
|
373
|
+
const rankedIndexes = new Set(members.map((member) => member.originalIndex));
|
|
374
|
+
let nextSortedMember = 0;
|
|
350
375
|
|
|
351
|
-
const reorderedBody =
|
|
376
|
+
const reorderedBody =
|
|
377
|
+
segmentTexts
|
|
378
|
+
.map((segmentText, index) =>
|
|
379
|
+
rankedIndexes.has(index) ? segmentTexts[sortedMembers[nextSortedMember++].originalIndex] : segmentText,
|
|
380
|
+
)
|
|
381
|
+
.join('') + suffix;
|
|
352
382
|
|
|
353
|
-
return (
|
|
383
|
+
return (/** @type {import('eslint').Rule.RuleFixer} */ fixer) =>
|
|
384
|
+
fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
|
|
354
385
|
};
|
|
355
386
|
|
|
356
387
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -379,7 +410,7 @@ const classMemberOrder = {
|
|
|
379
410
|
const members = node.body
|
|
380
411
|
.map(
|
|
381
412
|
/** @param {any} member @param {number} originalIndex */ (member, originalIndex) =>
|
|
382
|
-
toMemberEntry(member, originalIndex),
|
|
413
|
+
toMemberEntry(sourceCode, member, originalIndex),
|
|
383
414
|
)
|
|
384
415
|
.filter(Boolean);
|
|
385
416
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { rules } = require('@typescript-eslint/eslint-plugin');
|
|
5
|
+
|
|
6
|
+
const upstream = rules['consistent-type-definitions'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `@typescript-eslint/consistent-type-definitions` with `'type'`, except that an interface inside a
|
|
10
|
+
* `declare module`, `declare global` or `namespace` block is left alone.
|
|
11
|
+
*
|
|
12
|
+
* Only an interface merges into an existing declaration. The upstream fix turns
|
|
13
|
+
* `declare module '@ethlete/core' { interface EthleteColorThemeNameRegistry { … } }` into a type
|
|
14
|
+
* alias, which merges into nothing, and under `skipLibCheck` the registration disappears without an
|
|
15
|
+
* error.
|
|
16
|
+
*
|
|
17
|
+
* BAD:
|
|
18
|
+
* interface User { name: string }
|
|
19
|
+
*
|
|
20
|
+
* GOOD:
|
|
21
|
+
* type User = { name: string };
|
|
22
|
+
*
|
|
23
|
+
* declare module '@ethlete/core' {
|
|
24
|
+
* interface EthleteColorThemeNameRegistry { name: 'brand' }
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const isInsideModuleDeclaration = (sourceCode, node) =>
|
|
29
|
+
sourceCode.getAncestors(node).some((ancestor) => ancestor.type === 'TSModuleDeclaration');
|
|
30
|
+
|
|
31
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
32
|
+
const consistentTypeDefinitions = {
|
|
33
|
+
meta: {
|
|
34
|
+
...upstream.meta,
|
|
35
|
+
docs: {
|
|
36
|
+
description:
|
|
37
|
+
'Use `type` instead of `interface`, except for an interface that augments a module or the global scope.',
|
|
38
|
+
recommended: true,
|
|
39
|
+
},
|
|
40
|
+
schema: [],
|
|
41
|
+
},
|
|
42
|
+
create(context) {
|
|
43
|
+
const { sourceCode } = context;
|
|
44
|
+
|
|
45
|
+
const filteredContext = Object.create(context, {
|
|
46
|
+
options: { value: ['type'] },
|
|
47
|
+
report: {
|
|
48
|
+
value: (descriptor) => {
|
|
49
|
+
if ('node' in descriptor && isInsideModuleDeclaration(sourceCode, descriptor.node)) return;
|
|
50
|
+
|
|
51
|
+
context.report(descriptor);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return upstream.create(filteredContext);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
module.exports = consistentTypeDefinitions;
|
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
+
const { isImportedAs } = require('./internals/import-resolution');
|
|
5
6
|
const { buildAccessibilityFix } = require('./internals/member-accessibility-fix');
|
|
6
7
|
|
|
7
8
|
/**
|
|
9
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
8
10
|
* @param {any} node
|
|
9
11
|
*/
|
|
10
|
-
const isInjectCall = (node) => {
|
|
12
|
+
const isInjectCall = (sourceCode, node) => {
|
|
11
13
|
if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
|
|
12
14
|
if (node.value.type !== 'CallExpression') return false;
|
|
13
|
-
return
|
|
15
|
+
return isImportedAs(sourceCode, node.value.callee, 'inject');
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -36,7 +38,7 @@ const injectMemberAccessibility = {
|
|
|
36
38
|
|
|
37
39
|
return {
|
|
38
40
|
PropertyDefinition(node) {
|
|
39
|
-
if (!isInjectCall(node)) return;
|
|
41
|
+
if (!isInjectCall(context.sourceCode, node)) return;
|
|
40
42
|
|
|
41
43
|
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
42
44
|
if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* (or null when the property is not a reactive I/O declaration).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const { ANGULAR_CORE, getImportedName } = require('./import-resolution');
|
|
22
|
+
|
|
23
|
+
const ANGULAR_IO_SOURCES = [ANGULAR_CORE, '@angular/core/rxjs-interop'];
|
|
24
|
+
|
|
21
25
|
/** Factory identifier → I/O kind. */
|
|
22
26
|
const IO_FACTORIES = {
|
|
23
27
|
input: 'input',
|
|
@@ -31,42 +35,37 @@ const IO_FACTORIES = {
|
|
|
31
35
|
* plain form (`input(...)`) and the `.required` member form
|
|
32
36
|
* (`input.required(...)`).
|
|
33
37
|
*
|
|
38
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
34
39
|
* @param {any} callee
|
|
35
40
|
* @returns {string | null}
|
|
36
41
|
*/
|
|
37
|
-
const getFactoryName = (callee) => {
|
|
42
|
+
const getFactoryName = (sourceCode, callee) => {
|
|
38
43
|
if (!callee) return null;
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// input.required(...) / model.required(...)
|
|
43
|
-
if (
|
|
45
|
+
const isRequired =
|
|
44
46
|
callee.type === 'MemberExpression' &&
|
|
45
47
|
!callee.computed &&
|
|
46
|
-
callee.object.type === 'Identifier' &&
|
|
47
48
|
callee.property.type === 'Identifier' &&
|
|
48
|
-
callee.property.name === 'required'
|
|
49
|
-
) {
|
|
50
|
-
return callee.object.name;
|
|
51
|
-
}
|
|
49
|
+
callee.property.name === 'required';
|
|
52
50
|
|
|
53
|
-
return
|
|
51
|
+
return getImportedName(sourceCode, isRequired ? callee.object : callee, ANGULAR_IO_SOURCES);
|
|
54
52
|
};
|
|
55
53
|
|
|
56
54
|
/**
|
|
57
55
|
* Classifies a class property as an Angular signal input/model/output.
|
|
58
56
|
*
|
|
57
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
59
58
|
* @param {any} node PropertyDefinition
|
|
60
59
|
* @returns {{ kind: 'input' | 'model' | 'output', name: string, keyNode: any } | null}
|
|
61
60
|
*/
|
|
62
|
-
const getReactiveIo = (node) => {
|
|
61
|
+
const getReactiveIo = (sourceCode, node) => {
|
|
63
62
|
if (!node || node.type !== 'PropertyDefinition') return null;
|
|
64
63
|
if (node.computed || node.key.type !== 'Identifier') return null;
|
|
65
64
|
|
|
66
65
|
const value = node.value;
|
|
67
66
|
if (!value || value.type !== 'CallExpression') return null;
|
|
68
67
|
|
|
69
|
-
const factory = getFactoryName(value.callee);
|
|
68
|
+
const factory = getFactoryName(sourceCode, value.callee);
|
|
70
69
|
if (!factory) return null;
|
|
71
70
|
|
|
72
71
|
if (!Object.hasOwn(IO_FACTORIES, factory)) return null;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getAngularDecoratorName } = require('./import-resolution');
|
|
5
|
+
|
|
4
6
|
const fs = require('fs');
|
|
5
7
|
const path = require('path');
|
|
6
8
|
|
|
@@ -10,21 +12,22 @@ const path = require('path');
|
|
|
10
12
|
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
13
|
|
|
12
14
|
/**
|
|
15
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
13
16
|
* @param {any} decorator
|
|
14
17
|
*/
|
|
15
|
-
const getDecoratorCall = (decorator) => {
|
|
16
|
-
|
|
18
|
+
const getDecoratorCall = (sourceCode, decorator) => {
|
|
19
|
+
const name = getAngularDecoratorName(sourceCode, decorator);
|
|
20
|
+
if (!name) return null;
|
|
17
21
|
|
|
18
22
|
const expression = decorator.expression;
|
|
19
23
|
if (expression.type !== 'CallExpression') return null;
|
|
20
|
-
if (expression.callee.type !== 'Identifier') return null;
|
|
21
24
|
if (expression.arguments.length === 0) return null;
|
|
22
25
|
|
|
23
26
|
const metadata = expression.arguments[0];
|
|
24
27
|
if (!metadata || metadata.type !== 'ObjectExpression') return null;
|
|
25
28
|
|
|
26
29
|
return {
|
|
27
|
-
name
|
|
30
|
+
name,
|
|
28
31
|
metadata,
|
|
29
32
|
};
|
|
30
33
|
};
|
|
@@ -135,11 +138,12 @@ const isDynamicHostBindingKey = (keyName) => {
|
|
|
135
138
|
};
|
|
136
139
|
|
|
137
140
|
/**
|
|
141
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
138
142
|
* @param {any} classNode
|
|
139
143
|
*/
|
|
140
|
-
const getAngularMetadata = (classNode) => {
|
|
144
|
+
const getAngularMetadata = (sourceCode, classNode) => {
|
|
141
145
|
for (const decorator of classNode.decorators || []) {
|
|
142
|
-
const call = getDecoratorCall(decorator);
|
|
146
|
+
const call = getDecoratorCall(sourceCode, decorator);
|
|
143
147
|
if (!call) continue;
|
|
144
148
|
|
|
145
149
|
if (call.name === 'Component' || call.name === 'Directive') {
|
|
@@ -217,7 +221,7 @@ const getMemberName = (node) => {
|
|
|
217
221
|
* @param {import('eslint').Rule.RuleContext} context
|
|
218
222
|
*/
|
|
219
223
|
const isReferencedFromTemplateOrHost = (classNode, memberName, context) => {
|
|
220
|
-
const angularMetadata = getAngularMetadata(classNode);
|
|
224
|
+
const angularMetadata = getAngularMetadata(context.sourceCode, classNode);
|
|
221
225
|
|
|
222
226
|
if (!angularMetadata) {
|
|
223
227
|
return false;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const ANGULAR_CORE = '@angular/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
8
|
+
* @param {any} identifier
|
|
9
|
+
*/
|
|
10
|
+
const findVariable = (sourceCode, identifier) => {
|
|
11
|
+
/** @type {import('eslint').Scope.Scope | null} */
|
|
12
|
+
let scope = sourceCode.getScope(identifier);
|
|
13
|
+
|
|
14
|
+
while (scope) {
|
|
15
|
+
const variable = scope.set.get(identifier.name);
|
|
16
|
+
if (variable) return variable;
|
|
17
|
+
scope = scope.upper;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {any} specifier
|
|
25
|
+
*/
|
|
26
|
+
const getSpecifierImportedName = (specifier) => {
|
|
27
|
+
if (specifier.type === 'ImportDefaultSpecifier') return 'default';
|
|
28
|
+
if (specifier.type === 'ImportNamespaceSpecifier') return '*';
|
|
29
|
+
|
|
30
|
+
return specifier.imported.name ?? specifier.imported.value;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What an identifier is bound to: `{ source, name }` for an import (`name` is the exported
|
|
35
|
+
* name, `'*'` for a namespace), `{ source: null, name }` for an undeclared global, and `null`
|
|
36
|
+
* for a local binding.
|
|
37
|
+
*
|
|
38
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
39
|
+
* @param {any} identifier
|
|
40
|
+
* @returns {{ source: string | null; name: string } | null}
|
|
41
|
+
*/
|
|
42
|
+
const resolveIdentifier = (sourceCode, identifier) => {
|
|
43
|
+
const variable = findVariable(sourceCode, identifier);
|
|
44
|
+
if (!variable || variable.defs.length === 0) return { source: null, name: identifier.name };
|
|
45
|
+
|
|
46
|
+
const definition = /** @type {any} */ (variable.defs[0]);
|
|
47
|
+
if (definition.type !== 'ImportBinding') return null;
|
|
48
|
+
|
|
49
|
+
return { source: definition.parent.source.value, name: getSpecifierImportedName(definition.node) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The exported name `node` refers to when it is imported from one of `sources`, directly, under
|
|
54
|
+
* an alias, or as `namespace.name`. An undeclared identifier falls back to its own name, so code
|
|
55
|
+
* without an import statement still matches; any other binding yields `null`.
|
|
56
|
+
*
|
|
57
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
58
|
+
* @param {any} node
|
|
59
|
+
* @param {string | readonly string[]} sources
|
|
60
|
+
* @returns {string | null}
|
|
61
|
+
*/
|
|
62
|
+
const getImportedName = (sourceCode, node, sources) => {
|
|
63
|
+
const sourceList = typeof sources === 'string' ? [sources] : sources;
|
|
64
|
+
|
|
65
|
+
if (node?.type === 'Identifier') {
|
|
66
|
+
const resolved = resolveIdentifier(sourceCode, node);
|
|
67
|
+
if (!resolved) return null;
|
|
68
|
+
if (resolved.source === null) return resolved.name;
|
|
69
|
+
|
|
70
|
+
return sourceList.includes(resolved.source) && resolved.name !== '*' ? resolved.name : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
node?.type === 'MemberExpression' &&
|
|
75
|
+
!node.computed &&
|
|
76
|
+
node.object.type === 'Identifier' &&
|
|
77
|
+
node.property.type === 'Identifier'
|
|
78
|
+
) {
|
|
79
|
+
const resolved = resolveIdentifier(sourceCode, node.object);
|
|
80
|
+
if (!resolved || resolved.name !== '*' || resolved.source === null) return null;
|
|
81
|
+
|
|
82
|
+
return sourceList.includes(resolved.source) ? node.property.name : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
90
|
+
* @param {any} node
|
|
91
|
+
* @param {string} name
|
|
92
|
+
* @param {string | readonly string[]} [sources]
|
|
93
|
+
*/
|
|
94
|
+
const isImportedAs = (sourceCode, node, name, sources = ANGULAR_CORE) =>
|
|
95
|
+
getImportedName(sourceCode, node, sources) === name;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `@angular/core` name of a decorator such as `@Component(...)`, `@Cmp(...)` or
|
|
99
|
+
* `@ng.Component(...)`.
|
|
100
|
+
*
|
|
101
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
102
|
+
* @param {any} decorator
|
|
103
|
+
*/
|
|
104
|
+
const getAngularDecoratorName = (sourceCode, decorator) => {
|
|
105
|
+
if (decorator?.type !== 'Decorator') return null;
|
|
106
|
+
|
|
107
|
+
const expression = decorator.expression;
|
|
108
|
+
const target = expression.type === 'CallExpression' ? expression.callee : expression;
|
|
109
|
+
|
|
110
|
+
return getImportedName(sourceCode, target, ANGULAR_CORE);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The local name of a value import `{ importedName as local } from source`, or `null` when the
|
|
115
|
+
* file does not import it as a value.
|
|
116
|
+
*
|
|
117
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
118
|
+
* @param {string} source
|
|
119
|
+
* @param {string} importedName
|
|
120
|
+
*/
|
|
121
|
+
const getImportLocalName = (sourceCode, source, importedName) => {
|
|
122
|
+
for (const node of sourceCode.ast.body) {
|
|
123
|
+
if (node.type !== 'ImportDeclaration' || node.source.value !== source) continue;
|
|
124
|
+
if (/** @type {any} */ (node).importKind === 'type') continue;
|
|
125
|
+
|
|
126
|
+
for (const specifier of node.specifiers) {
|
|
127
|
+
if (specifier.type !== 'ImportSpecifier' || /** @type {any} */ (specifier).importKind === 'type') continue;
|
|
128
|
+
if (getSpecifierImportedName(specifier) === importedName) return specifier.local.name;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether `identifier` refers to a global such as `document` or `setTimeout`, rather than a local,
|
|
137
|
+
* parameter or import of the same name.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
140
|
+
* @param {any} identifier
|
|
141
|
+
*/
|
|
142
|
+
const isGlobalReference = (sourceCode, identifier) =>
|
|
143
|
+
identifier?.type === 'Identifier' && resolveIdentifier(sourceCode, identifier)?.source === null;
|
|
144
|
+
|
|
145
|
+
const MODULE_REFERENCE_SELECTOR =
|
|
146
|
+
'ImportDeclaration, ExportNamedDeclaration[source], ExportAllDeclaration, ImportExpression';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The module an import, re-export or dynamic `import('…')` references, with the names it takes by
|
|
150
|
+
* name. `export *`, a namespace or default import, and a dynamic import have no named entries.
|
|
151
|
+
*
|
|
152
|
+
* @param {any} node a node matched by `MODULE_REFERENCE_SELECTOR`
|
|
153
|
+
* @returns {{ source: string; named: { name: string; node: any }[] } | null}
|
|
154
|
+
*/
|
|
155
|
+
const getModuleReference = (node) => {
|
|
156
|
+
const sourceNode = node.source;
|
|
157
|
+
let source = null;
|
|
158
|
+
if (sourceNode?.type === 'Literal' && typeof sourceNode.value === 'string') source = sourceNode.value;
|
|
159
|
+
if (sourceNode?.type === 'TemplateLiteral' && sourceNode.expressions.length === 0) {
|
|
160
|
+
source = sourceNode.quasis[0].value.cooked;
|
|
161
|
+
}
|
|
162
|
+
if (source === null) return null;
|
|
163
|
+
|
|
164
|
+
/** @type {{ name: string; node: any }[]} */
|
|
165
|
+
const named = [];
|
|
166
|
+
if (node.type === 'ImportDeclaration') {
|
|
167
|
+
for (const specifier of node.specifiers) {
|
|
168
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
169
|
+
named.push({ name: getSpecifierImportedName(specifier), node: specifier });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (node.type === 'ExportNamedDeclaration') {
|
|
173
|
+
for (const specifier of node.specifiers) {
|
|
174
|
+
named.push({ name: specifier.local.name ?? specifier.local.value, node: specifier });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { source, named };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
ANGULAR_CORE,
|
|
183
|
+
MODULE_REFERENCE_SELECTOR,
|
|
184
|
+
getAngularDecoratorName,
|
|
185
|
+
getImportedName,
|
|
186
|
+
getImportLocalName,
|
|
187
|
+
getModuleReference,
|
|
188
|
+
isGlobalReference,
|
|
189
|
+
isImportedAs,
|
|
190
|
+
resolveIdentifier,
|
|
191
|
+
};
|