@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.24

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 (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +4 -5
  3. package/src/configs/recommended.js +23 -10
  4. package/src/index.js +6 -1
  5. package/src/rules/angular-decorator-property-order.js +20 -20
  6. package/src/rules/class-constant-property.js +35 -14
  7. package/src/rules/class-member-order.js +77 -46
  8. package/src/rules/guard-return-newline.js +5 -4
  9. package/src/rules/inject-member-accessibility.js +22 -24
  10. package/src/rules/internals/angular-io.js +31 -14
  11. package/src/rules/internals/angular-member-visibility.js +16 -12
  12. package/src/rules/internals/angular-metadata-fix.js +22 -0
  13. package/src/rules/internals/import-resolution.js +191 -0
  14. package/src/rules/internals/member-accessibility-fix.js +26 -0
  15. package/src/rules/no-angular-router-api.js +23 -29
  16. package/src/rules/no-angular-seo-services.js +21 -16
  17. package/src/rules/no-cdk-import.js +7 -14
  18. package/src/rules/no-direct-dom-manipulation.js +17 -0
  19. package/src/rules/no-document-cookie.js +3 -0
  20. package/src/rules/no-effect-cleanup-return.js +15 -9
  21. package/src/rules/no-empty-angular-metadata-arrays.js +6 -36
  22. package/src/rules/no-enum.js +115 -0
  23. package/src/rules/no-inject-chain.js +3 -1
  24. package/src/rules/no-leading-underscore-class-member.js +33 -3
  25. package/src/rules/no-legacy-angular-decorators.js +17 -21
  26. package/src/rules/no-legacy-prepare-without-injector.js +42 -26
  27. package/src/rules/no-legacy-query-import.js +7 -16
  28. package/src/rules/no-locale-id.js +6 -18
  29. package/src/rules/no-member-alias.js +21 -0
  30. package/src/rules/no-native-html-input-name.js +1 -1
  31. package/src/rules/no-pipe-logic.js +11 -17
  32. package/src/rules/no-readonly-signal.js +13 -5
  33. package/src/rules/no-redundant-internal.js +21 -2
  34. package/src/rules/no-redundant-on-push-change-detection.js +32 -56
  35. package/src/rules/no-rxjs-in-effect.js +12 -8
  36. package/src/rules/no-screaming-case-local.js +1 -0
  37. package/src/rules/no-standalone-flag.js +7 -36
  38. package/src/rules/no-subscribe-in-pipe.js +3 -2
  39. package/src/rules/no-template-literal-before-inline-template.js +3 -14
  40. package/src/rules/no-trivial-return-type.js +2 -0
  41. package/src/rules/no-trivial-wrapper-method.js +13 -16
  42. package/src/rules/no-type-only-import.js +7 -2
  43. package/src/rules/no-typed-injected-element-ref.js +23 -19
  44. package/src/rules/no-unused-class-member.js +34 -14
  45. package/src/rules/prefer-clone-equal.js +8 -6
  46. package/src/rules/prefer-concise-angular-host-directives.js +23 -29
  47. package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
  48. package/src/rules/prefer-element-dimensions.js +9 -10
  49. package/src/rules/prefer-linked-signal.js +7 -4
  50. package/src/rules/prefer-match-media.js +12 -16
  51. package/src/rules/prefer-present-tense-output.js +1 -1
  52. package/src/rules/prefer-rxjs-timer.js +3 -1
  53. package/src/rules/prefer-scroll-state.js +3 -67
  54. package/src/rules/prefer-static-boolean-properties.js +30 -0
  55. package/src/rules/prefer-viewport-size.js +1 -7
  56. package/src/rules/require-dollar-suffix.js +7 -4
  57. package/src/rules/require-form-submit.js +2 -28
  58. package/src/rules/require-on-push-change-detection.js +48 -36
  59. package/src/rules/require-view-encapsulation-none.js +48 -34
  60. package/src/rules/take-until-destroyed-last.js +64 -0
  61. package/src/rules/template-member-accessibility.js +12 -27
@@ -2,33 +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');
6
+ const { buildAccessibilityFix } = require('./internals/member-accessibility-fix');
5
7
 
6
8
  /**
9
+ * @param {import('eslint').SourceCode} sourceCode
7
10
  * @param {any} node
8
11
  */
9
- const isInjectCall = (node) => {
12
+ const isInjectCall = (sourceCode, node) => {
10
13
  if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
11
14
  if (node.value.type !== 'CallExpression') return false;
12
- return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
13
- };
14
-
15
- /**
16
- * @param {any} node
17
- * @param {import('eslint').SourceCode} sourceCode
18
- * @param {'private' | 'protected'} expectedAccessibility
19
- */
20
- const buildFix = (node, sourceCode, expectedAccessibility) => {
21
- const keyToken = sourceCode.getFirstToken(node.key);
22
- if (!keyToken) return null;
23
-
24
- if (node.accessibility) {
25
- const firstToken = sourceCode.getFirstToken(node);
26
- if (!firstToken) return null;
27
-
28
- return (fixer) => fixer.replaceText(firstToken, expectedAccessibility);
29
- }
30
-
31
- return (fixer) => fixer.insertTextBefore(keyToken, `${expectedAccessibility} `);
15
+ return isImportedAs(sourceCode, node.value.callee, 'inject');
32
16
  };
33
17
 
34
18
  /** @type {import('eslint').Rule.RuleModule} */
@@ -54,7 +38,7 @@ const injectMemberAccessibility = {
54
38
 
55
39
  return {
56
40
  PropertyDefinition(node) {
57
- if (!isInjectCall(node)) return;
41
+ if (!isInjectCall(context.sourceCode, node)) return;
58
42
 
59
43
  const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
60
44
  if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
@@ -63,6 +47,7 @@ const injectMemberAccessibility = {
63
47
  if (!memberName) return;
64
48
 
65
49
  const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
50
+ const isAbstractClass = classNode.abstract === true;
66
51
 
67
52
  if (isTemplateOrHostVisible) {
68
53
  if (node.accessibility === 'protected' || node.accessibility === 'public') return;
@@ -71,7 +56,7 @@ const injectMemberAccessibility = {
71
56
  node,
72
57
  messageId: 'shouldBeProtected',
73
58
  data: { name: memberName },
74
- fix: buildFix(node, sourceCode, 'protected'),
59
+ fix: buildAccessibilityFix(node, sourceCode, 'protected'),
75
60
  });
76
61
 
77
62
  return;
@@ -79,11 +64,24 @@ const injectMemberAccessibility = {
79
64
 
80
65
  if (node.accessibility === 'private' || node.accessibility === 'public') return;
81
66
 
67
+ if (isAbstractClass) {
68
+ if (node.accessibility === 'protected') return;
69
+
70
+ context.report({
71
+ node,
72
+ messageId: 'shouldBeProtected',
73
+ data: { name: memberName },
74
+ fix: buildAccessibilityFix(node, sourceCode, 'protected'),
75
+ });
76
+
77
+ return;
78
+ }
79
+
82
80
  context.report({
83
81
  node,
84
82
  messageId: 'shouldBePrivate',
85
83
  data: { name: memberName },
86
- fix: buildFix(node, sourceCode, 'private'),
84
+ fix: buildAccessibilityFix(node, sourceCode, 'private'),
87
85
  });
88
86
  },
89
87
  };
@@ -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,48 +35,61 @@ 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
 
71
+ if (!Object.hasOwn(IO_FACTORIES, factory)) return null;
72
72
  const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
73
73
  if (!kind) return null;
74
74
 
75
- return { kind, name: node.key.name, keyNode: node.key };
75
+ let name = node.key.name;
76
+ for (const argument of value.arguments) {
77
+ if (argument.type !== 'ObjectExpression') continue;
78
+
79
+ const aliasProperty = argument.properties.find(
80
+ (property) =>
81
+ property.type === 'Property' &&
82
+ !property.computed &&
83
+ ((property.key.type === 'Identifier' && property.key.name === 'alias') ||
84
+ (property.key.type === 'Literal' && property.key.value === 'alias')),
85
+ );
86
+ if (aliasProperty?.value.type === 'Literal' && typeof aliasProperty.value.value === 'string') {
87
+ name = aliasProperty.value.value;
88
+ break;
89
+ }
90
+ }
91
+
92
+ return { kind, name, keyNode: node.key };
76
93
  };
77
94
 
78
95
  module.exports = { getReactiveIo };
@@ -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
  };
@@ -67,8 +70,8 @@ const getStringValue = (node) => {
67
70
  if (!node) return null;
68
71
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
69
72
 
70
- if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
71
- return node.quasis[0].value.cooked || '';
73
+ if (node.type === 'TemplateLiteral') {
74
+ return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ');
72
75
  }
73
76
 
74
77
  return null;
@@ -111,9 +114,9 @@ const templateReferencesMember = (memberName, template) => {
111
114
 
112
115
  const escapedName = escapeRegExp(memberName);
113
116
  const patterns = [
114
- new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
115
- new RegExp(`@[a-zA-Z]+\\s*\\([\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\)`, 'u'),
116
- new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[\\s\\S]*?\\b${escapedName}\\b`, 'u'),
117
+ new RegExp(`\\{\\{[^{}]*\\b${escapedName}\\b[^{}]*\\}\\}`, 'u'),
118
+ new RegExp(`@[a-zA-Z]+(?:\\s+if)?\\s*\\([^)]*\\b${escapedName}\\b[^)]*\\)`, 'u'),
119
+ new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[^;]*\\b${escapedName}\\b`, 'u'),
117
120
  new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
118
121
  new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
119
122
  new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
@@ -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,22 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * @param {import('eslint').SourceCode} sourceCode
6
+ * @param {any} metadata
7
+ * @param {any} property
8
+ */
9
+ const getMetadataEntryRemovalRange = (sourceCode, metadata, property) => {
10
+ const entries = metadata.properties;
11
+ const index = entries.indexOf(property);
12
+ const openingBrace = sourceCode.getFirstToken(metadata);
13
+ const closingBrace = sourceCode.getLastToken(metadata);
14
+
15
+ if (!openingBrace || !closingBrace || index === -1) return property.range;
16
+ if (entries.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
17
+ if (index < entries.length - 1) return [property.range[0], entries[index + 1].range[0]];
18
+
19
+ return [entries[index - 1].range[1], property.range[1]];
20
+ };
21
+
22
+ module.exports = { getMetadataEntryRemovalRange };
@@ -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
+ };
@@ -0,0 +1,26 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * @param {any} node
6
+ * @param {import('eslint').SourceCode} sourceCode
7
+ * @param {'private' | 'protected' | 'public'} accessibility
8
+ */
9
+ const buildAccessibilityFix = (node, sourceCode, accessibility) => {
10
+ const decoratorEnd = node.decorators?.at(-1)?.range[1] ?? node.range[0];
11
+ const tokens = sourceCode.getTokens(node);
12
+ const accessibilityToken = tokens.find(
13
+ (token) => token.range[0] >= decoratorEnd && token.value === node.accessibility,
14
+ );
15
+
16
+ if (accessibilityToken) {
17
+ return (fixer) => fixer.replaceText(accessibilityToken, accessibility);
18
+ }
19
+
20
+ const insertionToken = tokens.find((token) => token.range[0] >= decoratorEnd);
21
+ if (!insertionToken) return null;
22
+
23
+ return (fixer) => fixer.insertTextBefore(insertionToken, `${accessibility} `);
24
+ };
25
+
26
+ module.exports = { buildAccessibilityFix };
@@ -35,6 +35,10 @@
35
35
  * injectQueryParamChanges(), injectPathParamChanges()
36
36
  */
37
37
 
38
+ const { MODULE_REFERENCE_SELECTOR, getModuleReference, isImportedAs } = require('./internals/import-resolution');
39
+
40
+ const ANGULAR_ROUTER = '@angular/router';
41
+
38
42
  /**
39
43
  * Router properties that expose route state and must not be read directly.
40
44
  * @type {Map<string, string>}
@@ -66,6 +70,17 @@ const noAngularRouterApi = {
66
70
  schema: [],
67
71
  },
68
72
  create(context) {
73
+ const sourceCode = context.sourceCode;
74
+
75
+ /**
76
+ * @param {any} call
77
+ * @param {string} token
78
+ */
79
+ const isInjectOf = (call, token) =>
80
+ call?.type === 'CallExpression' &&
81
+ isImportedAs(sourceCode, call.callee, 'inject') &&
82
+ isImportedAs(sourceCode, call.arguments[0], token, ANGULAR_ROUTER);
83
+
69
84
  /**
70
85
  * Names of variables/properties assigned from inject(Router).
71
86
  * We track identifiers whose initializer is inject(Router) so we can
@@ -78,28 +93,14 @@ const noAngularRouterApi = {
78
93
  // ── Track inject(Router) bindings ──────────────────────────────────────
79
94
  // const router = inject(Router);
80
95
  VariableDeclarator(node) {
81
- if (
82
- node.init?.type === 'CallExpression' &&
83
- node.init.callee.type === 'Identifier' &&
84
- node.init.callee.name === 'inject' &&
85
- node.init.arguments[0]?.type === 'Identifier' &&
86
- node.init.arguments[0].name === 'Router' &&
87
- node.id.type === 'Identifier'
88
- ) {
96
+ if (isInjectOf(node.init, 'Router') && node.id.type === 'Identifier') {
89
97
  routerBindings.add(node.id.name);
90
98
  }
91
99
  },
92
100
 
93
101
  // ── Track class property assignments: router = inject(Router) ──────────
94
102
  PropertyDefinition(node) {
95
- if (
96
- node.value?.type === 'CallExpression' &&
97
- node.value.callee.type === 'Identifier' &&
98
- node.value.callee.name === 'inject' &&
99
- node.value.arguments[0]?.type === 'Identifier' &&
100
- node.value.arguments[0].name === 'Router' &&
101
- node.key.type === 'Identifier'
102
- ) {
103
+ if (isInjectOf(node.value, 'Router') && node.key.type === 'Identifier') {
103
104
  routerBindings.add(node.key.name);
104
105
  }
105
106
  },
@@ -130,24 +131,17 @@ const noAngularRouterApi = {
130
131
  },
131
132
 
132
133
  // ── ActivatedRoute import ───────────────────────────────────────────────
133
- ImportDeclaration(node) {
134
- if (node.source.value !== '@angular/router') return;
135
- for (const specifier of node.specifiers) {
136
- if (specifier.type !== 'ImportSpecifier') continue;
137
- if (specifier.imported.name === 'ActivatedRoute') {
138
- context.report({ node, messageId: 'noActivatedRoute' });
139
- return;
140
- }
134
+ [MODULE_REFERENCE_SELECTOR](node) {
135
+ const reference = getModuleReference(node);
136
+ if (reference?.source !== ANGULAR_ROUTER) return;
137
+ if (reference.named.some((entry) => entry.name === 'ActivatedRoute')) {
138
+ context.report({ node, messageId: 'noActivatedRoute' });
141
139
  }
142
140
  },
143
141
 
144
142
  // ── inject(ActivatedRoute) ──────────────────────────────────────────────
145
143
  CallExpression(node) {
146
- const { callee } = node;
147
- if (callee.type !== 'Identifier' || callee.name !== 'inject') return;
148
- const arg = node.arguments[0];
149
- if (arg?.type !== 'Identifier') return;
150
- if (arg.name === 'ActivatedRoute') {
144
+ if (isInjectOf(node, 'ActivatedRoute')) {
151
145
  context.report({ node, messageId: 'noActivatedRoute' });
152
146
  }
153
147
  },
@@ -1,6 +1,13 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const {
5
+ MODULE_REFERENCE_SELECTOR,
6
+ getImportedName,
7
+ getModuleReference,
8
+ isImportedAs,
9
+ } = require('./internals/import-resolution');
10
+
4
11
  /**
5
12
  * Disallows injecting Angular's `Title` and `Meta` services from
6
13
  * `@angular/platform-browser` and importing them from that module.
@@ -18,9 +25,9 @@
18
25
  * import { Title, Meta } from '@angular/platform-browser';
19
26
  *
20
27
  * ✅ Use the @ethlete/core SEO utilities:
21
- * import { injectTitleBinding, injectMetaBinding } from '@ethlete/core';
22
- * const titleBinding = injectTitleBinding();
23
- * const metaBinding = injectMetaBinding();
28
+ * import { applyHeadTitleBinding, applyMetaBinding } from '@ethlete/core';
29
+ * applyHeadTitleBinding(title);
30
+ * applyMetaBinding(meta);
24
31
  */
25
32
 
26
33
  /** @type {import('eslint').Rule.RuleModule} */
@@ -32,12 +39,12 @@ const rule = {
32
39
  },
33
40
  schema: [],
34
41
  messages: {
35
- noInjectTitle: "Do not inject Angular's Title service. Use injectTitleBinding() from '@ethlete/core' instead.",
36
- noInjectMeta: "Do not inject Angular's Meta service. Use injectMetaBinding() from '@ethlete/core' instead.",
42
+ noInjectTitle: "Do not inject Angular's Title service. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
43
+ noInjectMeta: "Do not inject Angular's Meta service. Use applyMetaBinding() from '@ethlete/core' instead.",
37
44
  noImportTitle:
38
- "Do not import Title from '@angular/platform-browser'. Use injectTitleBinding() from '@ethlete/core' instead.",
45
+ "Do not import Title from '@angular/platform-browser'. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
39
46
  noImportMeta:
40
- "Do not import Meta from '@angular/platform-browser'. Use injectMetaBinding() from '@ethlete/core' instead.",
47
+ "Do not import Meta from '@angular/platform-browser'. Use applyMetaBinding() from '@ethlete/core' instead.",
41
48
  },
42
49
  },
43
50
  create(context) {
@@ -48,11 +55,11 @@ const rule = {
48
55
  */
49
56
  const injectTokenName = (node) => {
50
57
  if (node.type !== 'CallExpression') return null;
51
- if (node.callee.type !== 'Identifier' || node.callee.name !== 'inject') return null;
58
+ if (!isImportedAs(context.sourceCode, node.callee, 'inject')) return null;
52
59
  const firstArg = node.arguments[0];
53
60
  if (!firstArg) return null;
54
- if (firstArg.type === 'Identifier') return firstArg.name;
55
- return null;
61
+
62
+ return getImportedName(context.sourceCode, firstArg, '@angular/platform-browser');
56
63
  };
57
64
 
58
65
  return {
@@ -64,13 +71,11 @@ const rule = {
64
71
  },
65
72
 
66
73
  // Detect: import { Title, Meta } from '@angular/platform-browser'
67
- ImportDeclaration(node) {
68
- const decl = /** @type {any} */ (node);
69
- if (decl.source.value !== '@angular/platform-browser') return;
74
+ [MODULE_REFERENCE_SELECTOR](node) {
75
+ const reference = getModuleReference(node);
76
+ if (reference?.source !== '@angular/platform-browser') return;
70
77
 
71
- for (const specifier of decl.specifiers) {
72
- if (specifier.type !== 'ImportSpecifier') continue;
73
- const imported = specifier.imported.name ?? specifier.imported.value;
78
+ for (const { name: imported, node: specifier } of reference.named) {
74
79
  if (imported === 'Title') context.report({ node: specifier, messageId: 'noImportTitle' });
75
80
  if (imported === 'Meta') context.report({ node: specifier, messageId: 'noImportMeta' });
76
81
  }