@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.26

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