@ethlete/eslint-plugin 1.0.0-next.24 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -2
  4. package/src/configs/recommended.js +37 -27
  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 +8 -2
  10. package/src/rules/angular-decorator-property-order.js +24 -7
  11. package/src/rules/class-constant-property.js +1 -1
  12. package/src/rules/class-member-order.js +60 -8
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/enforce-routing-view-naming.js +34 -23
  15. package/src/rules/guard-return-newline.js +4 -7
  16. package/src/rules/internals/angular-member-visibility.js +24 -2
  17. package/src/rules/internals/angular-metadata-fix.js +231 -1
  18. package/src/rules/internals/angular-version.js +50 -0
  19. package/src/rules/internals/implemented-contract-members.js +26 -11
  20. package/src/rules/no-angular-router-api.js +86 -33
  21. package/src/rules/no-angular-seo-services.js +1 -0
  22. package/src/rules/no-async-await.js +5 -1
  23. package/src/rules/no-cdk-import.js +32 -7
  24. package/src/rules/no-csp-unsafe.js +362 -0
  25. package/src/rules/no-direct-dom-manipulation.js +50 -28
  26. package/src/rules/no-document-cookie.js +51 -8
  27. package/src/rules/no-dom-query.js +5 -2
  28. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  29. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  30. package/src/rules/no-impure-top-level-provider.js +42 -7
  31. package/src/rules/no-leading-underscore-class-member.js +27 -2
  32. package/src/rules/no-legacy-angular-decorators.js +11 -6
  33. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  34. package/src/rules/no-legacy-query-import.js +61 -16
  35. package/src/rules/no-member-alias.js +0 -5
  36. package/src/rules/no-native-observers.js +89 -24
  37. package/src/rules/no-pipe-logic.js +1 -2
  38. package/src/rules/no-readonly-signal.js +1 -5
  39. package/src/rules/no-redundant-internal.js +5 -2
  40. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  41. package/src/rules/no-rxjs-in-effect.js +7 -5
  42. package/src/rules/no-screaming-case-local.js +3 -5
  43. package/src/rules/no-standalone-flag.js +1 -0
  44. package/src/rules/no-subscribe-with-body.js +30 -1
  45. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  46. package/src/rules/no-trivial-return-type.js +18 -1
  47. package/src/rules/no-unused-class-member.js +1 -1
  48. package/src/rules/no-window-location.js +60 -22
  49. package/src/rules/prefer-clone-equal.js +61 -8
  50. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  51. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  52. package/src/rules/prefer-element-dimensions.js +264 -11
  53. package/src/rules/prefer-linked-signal.js +37 -39
  54. package/src/rules/prefer-match-media.js +17 -3
  55. package/src/rules/prefer-rxjs-timer.js +26 -4
  56. package/src/rules/prefer-scroll-state.js +0 -4
  57. package/src/rules/prefer-static-boolean-properties.js +1 -0
  58. package/src/rules/prefer-viewport-size.js +153 -16
  59. package/src/rules/require-dollar-suffix.js +11 -3
  60. package/src/rules/require-form-submit.js +4 -1
  61. package/src/rules/require-on-push-change-detection.js +7 -259
  62. package/src/rules/require-view-encapsulation-none.js +6 -214
  63. package/src/rules/template-member-accessibility.js +12 -4
@@ -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;
@@ -10,6 +10,8 @@
10
10
  * loadComponent: () => import('./items-list-view/items-list-view.component').then((m) => m.ItemsListViewComponent)
11
11
  */
12
12
 
13
+ const VIEW_PATH = /-view(?![\w-])/;
14
+
13
15
  /** @type {import('eslint').Rule.RuleModule} */
14
16
  const enforceRoutingViewNaming = {
15
17
  meta: {
@@ -17,6 +19,7 @@ const enforceRoutingViewNaming = {
17
19
  docs: {
18
20
  description:
19
21
  'Routing components must import from a path containing "-view" and use a class name ending in "ViewComponent".',
22
+ recommended: true,
20
23
  },
21
24
  messages: {
22
25
  pathMustContainView: 'Routing import path must contain "-view". Got: "{{path}}".',
@@ -25,48 +28,56 @@ const enforceRoutingViewNaming = {
25
28
  schema: [],
26
29
  },
27
30
  create(context) {
31
+ /** @param {any} importExpr */
32
+ const checkImportPath = (importExpr) => {
33
+ if (!importExpr || importExpr.type !== 'ImportExpression') return;
34
+
35
+ const source = importExpr.source;
36
+ if (source && source.type === 'Literal' && typeof source.value === 'string' && !VIEW_PATH.test(source.value)) {
37
+ context.report({
38
+ node: source,
39
+ messageId: 'pathMustContainView',
40
+ data: { path: source.value },
41
+ });
42
+ }
43
+ };
44
+
28
45
  return {
29
46
  Property(node) {
30
47
  const key = /** @type {any} */ (node).key;
31
48
  if (key.type !== 'Identifier' || key.name !== 'loadComponent') return;
32
49
 
33
- // Must be an arrow function: () => import(...).then(m => m.XxxViewComponent)
34
50
  const arrow = /** @type {any} */ (node).value;
35
51
  if (!arrow || arrow.type !== 'ArrowFunctionExpression') return;
36
52
 
37
53
  const body = arrow.body;
38
- if (!body || body.type !== 'CallExpression') return;
54
+ if (!body) return;
55
+
56
+ if (body.type === 'ImportExpression') {
57
+ checkImportPath(body);
58
+ return;
59
+ }
60
+
61
+ if (body.type !== 'CallExpression') return;
39
62
 
40
63
  const callee = body.callee;
41
64
  if (!callee || callee.type !== 'MemberExpression') return;
42
65
  if (callee.property.type !== 'Identifier' || callee.property.name !== 'then') return;
43
66
 
44
- // Check the import path contains "-view"
45
- const importExpr = callee.object;
46
- if (importExpr && importExpr.type === 'ImportExpression') {
47
- const source = importExpr.source;
48
- if (source && source.type === 'Literal' && typeof source.value === 'string') {
49
- if (!source.value.includes('-view')) {
50
- context.report({
51
- node: source,
52
- messageId: 'pathMustContainView',
53
- data: { path: source.value },
54
- });
55
- }
56
- }
57
- }
58
-
59
- // Check the class name in .then(m => m.XxxViewComponent)
60
- const args = body.arguments;
61
- if (!args || args.length === 0) return;
67
+ checkImportPath(callee.object);
62
68
 
63
- const callback = args[0];
69
+ const callback = body.arguments?.[0];
64
70
  if (!callback || callback.type !== 'ArrowFunctionExpression') return;
65
71
 
66
72
  const cbBody = callback.body;
67
- if (!cbBody || cbBody.type !== 'MemberExpression') return;
73
+ const returned =
74
+ cbBody?.type === 'BlockStatement'
75
+ ? cbBody.body.find(/** @param {any} statement */ (statement) => statement.type === 'ReturnStatement')
76
+ ?.argument
77
+ : cbBody;
78
+ if (!returned || returned.type !== 'MemberExpression') return;
68
79
 
69
- const prop = cbBody.property;
80
+ const prop = returned.property;
70
81
  if (!prop || prop.type !== 'Identifier') return;
71
82
 
72
83
  if (!prop.name.endsWith('ViewComponent')) {
@@ -25,6 +25,7 @@ const rule = {
25
25
  type: 'layout',
26
26
  docs: {
27
27
  description: 'Require an empty line before a return in a multi-statement if-block (guard clause).',
28
+ recommended: true,
28
29
  },
29
30
  fixable: 'whitespace',
30
31
  schema: [],
@@ -37,18 +38,14 @@ const rule = {
37
38
  ReturnStatement(node) {
38
39
  const blockParent = node.parent;
39
40
 
40
- // Must be directly inside a BlockStatement
41
41
  if (!blockParent || blockParent.type !== 'BlockStatement') return;
42
42
 
43
- // The block's parent must be an IfStatement's consequent
44
43
  const ifStatement = blockParent.parent;
45
44
  if (!ifStatement || ifStatement.type !== 'IfStatement') return;
46
45
  if (ifStatement.consequent !== blockParent) return;
47
46
 
48
- // Block must have more than one statement (multi-statement guard)
49
47
  if (blockParent.body.length <= 1) return;
50
48
 
51
- // The return must be the last statement in the block
52
49
  if (blockParent.body[blockParent.body.length - 1] !== node) return;
53
50
 
54
51
  const prevStatement = blockParent.body[blockParent.body.length - 2];
@@ -56,11 +53,11 @@ const rule = {
56
53
  if (!node.loc || !prevStatement.loc) return;
57
54
 
58
55
  const prevEndLine = prevStatement.loc.end.line;
59
- const returnStartLine = node.loc.start.line;
56
+ const sourceCode = context.sourceCode;
60
57
 
61
- if (returnStartLine - prevEndLine >= 2) return;
58
+ const textBetween = sourceCode.text.slice(prevStatement.range[1], node.range[0]);
62
59
 
63
- const sourceCode = context.sourceCode;
60
+ if (/\n[ \t]*\r?\n/.test(textBetween)) return;
64
61
 
65
62
  context.report({
66
63
  node,
@@ -92,6 +92,28 @@ const readFileIfExists = (filePath) => {
92
92
  return null;
93
93
  };
94
94
 
95
+ /** @type {WeakMap<import('eslint').SourceCode, Map<string, string | null>>} */
96
+ const TEMPLATE_TEXT_CACHE = new WeakMap();
97
+
98
+ /**
99
+ * @param {import('eslint').SourceCode} sourceCode
100
+ * @param {string} templatePath
101
+ */
102
+ const readTemplateText = (sourceCode, templatePath) => {
103
+ let templates = TEMPLATE_TEXT_CACHE.get(sourceCode);
104
+
105
+ if (!templates) {
106
+ templates = new Map();
107
+ TEMPLATE_TEXT_CACHE.set(sourceCode, templates);
108
+ }
109
+
110
+ if (!templates.has(templatePath)) {
111
+ templates.set(templatePath, readFileIfExists(templatePath));
112
+ }
113
+
114
+ return templates.get(templatePath) ?? null;
115
+ };
116
+
95
117
  /**
96
118
  * @param {import('eslint').Rule.RuleContext} context
97
119
  */
@@ -183,7 +205,7 @@ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =
183
205
 
184
206
  if (templateUrl && filename) {
185
207
  const templatePath = path.resolve(path.dirname(filename), templateUrl);
186
- const templateText = readFileIfExists(templatePath);
208
+ const templateText = readTemplateText(context.sourceCode, templatePath);
187
209
  if (templateReferencesMember(memberName, templateText)) {
188
210
  return true;
189
211
  }
@@ -209,7 +231,7 @@ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =
209
231
  * @param {any} node
210
232
  */
211
233
  const getMemberName = (node) => {
212
- if (node.key?.type === 'Identifier') return node.key.name;
234
+ if (node.key?.type === 'Identifier' && !node.computed) return node.key.name;
213
235
  if (node.key?.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
214
236
 
215
237
  return null;
@@ -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
- if (SOURCE_FILE_CACHE.has(filePath)) {
35
- return SOURCE_FILE_CACHE.get(filePath) ?? null;
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
- if (fileText === null) {
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({