@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -3
  4. package/src/configs/recommended.js +33 -11
  5. package/src/generators/generators.json +10 -0
  6. package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
  7. package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
  8. package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
  9. package/src/index.js +10 -2
  10. package/src/rules/angular-decorator-property-order.js +20 -20
  11. package/src/rules/class-constant-property.js +8 -14
  12. package/src/rules/class-member-order.js +77 -46
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/inject-member-accessibility.js +5 -3
  15. package/src/rules/internals/angular-io.js +12 -13
  16. package/src/rules/internals/angular-member-visibility.js +11 -7
  17. package/src/rules/internals/import-resolution.js +191 -0
  18. package/src/rules/no-angular-router-api.js +23 -29
  19. package/src/rules/no-angular-seo-services.js +14 -9
  20. package/src/rules/no-cdk-import.js +7 -14
  21. package/src/rules/no-csp-unsafe.js +362 -0
  22. package/src/rules/no-document-cookie.js +3 -0
  23. package/src/rules/no-effect-cleanup-return.js +7 -3
  24. package/src/rules/no-empty-angular-metadata-arrays.js +3 -17
  25. package/src/rules/no-inject-chain.js +3 -1
  26. package/src/rules/no-leading-underscore-class-member.js +26 -3
  27. package/src/rules/no-legacy-angular-decorators.js +9 -21
  28. package/src/rules/no-legacy-query-import.js +6 -15
  29. package/src/rules/no-locale-id.js +4 -4
  30. package/src/rules/no-member-alias.js +11 -7
  31. package/src/rules/no-native-html-input-name.js +1 -1
  32. package/src/rules/no-pipe-logic.js +10 -17
  33. package/src/rules/no-redundant-on-push-change-detection.js +9 -23
  34. package/src/rules/no-rxjs-in-effect.js +12 -8
  35. package/src/rules/no-screaming-case-local.js +1 -0
  36. package/src/rules/no-standalone-flag.js +3 -17
  37. package/src/rules/no-subscribe-in-pipe.js +3 -2
  38. package/src/rules/no-template-literal-before-inline-template.js +3 -14
  39. package/src/rules/no-trivial-return-type.js +1 -0
  40. package/src/rules/no-trivial-wrapper-method.js +10 -16
  41. package/src/rules/no-typed-injected-element-ref.js +14 -17
  42. package/src/rules/no-unused-class-member.js +6 -9
  43. package/src/rules/prefer-clone-equal.js +8 -6
  44. package/src/rules/prefer-concise-angular-host-directives.js +23 -29
  45. package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
  46. package/src/rules/prefer-element-dimensions.js +9 -10
  47. package/src/rules/prefer-linked-signal.js +42 -41
  48. package/src/rules/prefer-match-media.js +10 -14
  49. package/src/rules/prefer-present-tense-output.js +1 -1
  50. package/src/rules/prefer-rxjs-timer.js +3 -1
  51. package/src/rules/require-dollar-suffix.js +7 -4
  52. package/src/rules/require-on-push-change-detection.js +17 -25
  53. package/src/rules/require-view-encapsulation-none.js +17 -25
  54. package/src/rules/take-until-destroyed-last.js +64 -0
  55. 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 = getCallRootName(node.value);
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 apiName = getCallRootName(node.value);
93
- return apiName !== null && (apiName === 'inject' || INJECT_HELPER_NAME_PATTERN.test(apiName));
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 getSegmentEntries = (sourceCode, classBodyNode, members) => {
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
- const segmentEntries = members.map((member) => {
292
- const segmentText = sourceCode.text.slice(segmentStart, member.node.range[1]);
293
- segmentStart = member.node.range[1];
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
- return {
296
- ...member,
297
- segmentText,
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
- segmentEntries,
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 segments = getSegmentEntries(sourceCode, classBodyNode, members);
360
+ const isAlreadySorted = sortedMembers.every((member, index) => member === members[index]);
328
361
 
329
- if (!segments) {
362
+ if (isAlreadySorted) {
330
363
  return null;
331
364
  }
332
365
 
333
- const { closingBrace, openingBrace, segmentEntries, suffix } = segments;
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 (sortedSegmentEntries.some((entry) => entry === undefined)) {
368
+ if (!segments) {
342
369
  return null;
343
370
  }
344
371
 
345
- const isAlreadySorted = sortedSegmentEntries.every((entry, index) => entry === segmentEntries[index]);
346
-
347
- if (isAlreadySorted) {
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 = sortedSegmentEntries.map((entry) => entry.segmentText).join('') + suffix;
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 (fixer) => fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
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 node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
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
- if (callee.type === 'Identifier') return callee.name;
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 null;
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
- if (!decorator || decorator.type !== 'Decorator') return null;
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: expression.callee.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
+ };