@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.10

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 +64 -0
  2. package/package.json +6 -5
  3. package/src/configs/recommended.js +414 -0
  4. package/src/index.js +138 -0
  5. package/src/rules/angular-decorator-property-order.js +205 -0
  6. package/src/rules/class-constant-property.js +365 -0
  7. package/src/rules/class-member-order.js +456 -0
  8. package/src/rules/enforce-routing-view-naming.js +84 -0
  9. package/src/rules/guard-return-newline.js +81 -0
  10. package/src/rules/inject-member-accessibility.js +93 -0
  11. package/src/rules/internals/angular-member-visibility.js +233 -0
  12. package/src/rules/internals/implemented-contract-members.js +294 -0
  13. package/src/rules/no-angular-router-api.js +158 -0
  14. package/src/rules/no-angular-seo-services.js +82 -0
  15. package/src/rules/no-direct-dom-manipulation.js +160 -0
  16. package/src/rules/no-document-cookie.js +55 -0
  17. package/src/rules/no-dom-query.js +81 -0
  18. package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
  19. package/src/rules/no-empty-newlines-between-imports.js +45 -0
  20. package/src/rules/no-inject-chain.js +44 -0
  21. package/src/rules/no-leading-underscore-class-member.js +120 -0
  22. package/src/rules/no-legacy-angular-decorators.js +405 -0
  23. package/src/rules/no-locale-id.js +57 -0
  24. package/src/rules/no-member-alias.js +151 -0
  25. package/src/rules/no-native-observers.js +102 -0
  26. package/src/rules/no-pipe-logic.js +76 -0
  27. package/src/rules/no-public-property.js +85 -0
  28. package/src/rules/no-readonly-signal.js +119 -0
  29. package/src/rules/no-redundant-internal.js +94 -0
  30. package/src/rules/no-redundant-on-push-change-detection.js +237 -0
  31. package/src/rules/no-rxjs-in-effect.js +88 -0
  32. package/src/rules/no-screaming-case-local.js +113 -0
  33. package/src/rules/no-standalone-flag.js +95 -0
  34. package/src/rules/no-subscribe-in-pipe.js +89 -0
  35. package/src/rules/no-subscribe-with-body.js +95 -0
  36. package/src/rules/no-trivial-return-type.js +104 -0
  37. package/src/rules/no-trivial-wrapper-method.js +120 -0
  38. package/src/rules/no-type-only-import.js +79 -0
  39. package/src/rules/no-typed-injected-element-ref.js +88 -0
  40. package/src/rules/no-unused-class-member.js +209 -0
  41. package/src/rules/no-window-location.js +109 -0
  42. package/src/rules/prefer-clone-equal.js +112 -0
  43. package/src/rules/prefer-concise-angular-host-directives.js +225 -0
  44. package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
  45. package/src/rules/prefer-element-dimensions.js +128 -0
  46. package/src/rules/prefer-linked-signal.js +124 -0
  47. package/src/rules/prefer-match-media.js +101 -0
  48. package/src/rules/prefer-rxjs-timer.js +82 -0
  49. package/src/rules/prefer-scroll-state.js +158 -0
  50. package/src/rules/prefer-viewport-size.js +75 -0
  51. package/src/rules/require-dollar-suffix.js +123 -0
  52. package/src/rules/require-on-push-change-detection.js +320 -0
  53. package/src/rules/require-view-encapsulation-none.js +264 -0
  54. package/src/rules/template-member-accessibility.js +204 -0
@@ -0,0 +1,233 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ /**
8
+ * @param {string} value
9
+ */
10
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+
12
+ /**
13
+ * @param {any} decorator
14
+ */
15
+ const getDecoratorCall = (decorator) => {
16
+ if (!decorator || decorator.type !== 'Decorator') return null;
17
+
18
+ const expression = decorator.expression;
19
+ if (expression.type !== 'CallExpression') return null;
20
+ if (expression.callee.type !== 'Identifier') return null;
21
+ if (expression.arguments.length === 0) return null;
22
+
23
+ const metadata = expression.arguments[0];
24
+ if (!metadata || metadata.type !== 'ObjectExpression') return null;
25
+
26
+ return {
27
+ name: expression.callee.name,
28
+ metadata,
29
+ };
30
+ };
31
+
32
+ /**
33
+ * @param {any} property
34
+ */
35
+ const getPropertyKeyName = (property) => {
36
+ if (!property || property.computed) return null;
37
+
38
+ const key = property.key;
39
+ if (key.type === 'Identifier') return key.name;
40
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
41
+
42
+ return null;
43
+ };
44
+
45
+ /**
46
+ * @param {any} metadata
47
+ * @param {string} propertyName
48
+ */
49
+ const getMetadataProperty = (metadata, propertyName) => {
50
+ for (const property of metadata.properties) {
51
+ if (!property || property.type !== 'Property' || property.computed) continue;
52
+
53
+ const keyName = getPropertyKeyName(property);
54
+
55
+ if (keyName === propertyName) {
56
+ return property;
57
+ }
58
+ }
59
+
60
+ return null;
61
+ };
62
+
63
+ /**
64
+ * @param {any} node
65
+ */
66
+ const getStringValue = (node) => {
67
+ if (!node) return null;
68
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
69
+
70
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
71
+ return node.quasis[0].value.cooked || '';
72
+ }
73
+
74
+ return null;
75
+ };
76
+
77
+ /**
78
+ * @param {string} filePath
79
+ */
80
+ const readFileIfExists = (filePath) => {
81
+ try {
82
+ if (fs.existsSync(filePath)) {
83
+ return fs.readFileSync(filePath, 'utf8');
84
+ }
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ return null;
90
+ };
91
+
92
+ /**
93
+ * @param {import('eslint').Rule.RuleContext} context
94
+ */
95
+ const getContextFilename = (context) => {
96
+ const filename = context.physicalFilename || context.filename;
97
+
98
+ if (!filename || filename === '<input>' || filename === '<text>') {
99
+ return null;
100
+ }
101
+
102
+ return filename;
103
+ };
104
+
105
+ /**
106
+ * @param {string} memberName
107
+ * @param {string | null} template
108
+ */
109
+ const templateReferencesMember = (memberName, template) => {
110
+ if (!template) return false;
111
+
112
+ const escapedName = escapeRegExp(memberName);
113
+ 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(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
118
+ new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
119
+ new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
120
+ new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
121
+ new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
122
+ new RegExp(`\\([^\\)]+\\)\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
123
+ ];
124
+
125
+ return patterns.some((pattern) => pattern.test(template));
126
+ };
127
+
128
+ /**
129
+ * @param {string | null} keyName
130
+ */
131
+ const isDynamicHostBindingKey = (keyName) => {
132
+ if (!keyName) return false;
133
+
134
+ return keyName.startsWith('[') || keyName.startsWith('(') || keyName.startsWith('@');
135
+ };
136
+
137
+ /**
138
+ * @param {any} classNode
139
+ */
140
+ const getAngularMetadata = (classNode) => {
141
+ for (const decorator of classNode.decorators || []) {
142
+ const call = getDecoratorCall(decorator);
143
+ if (!call) continue;
144
+
145
+ if (call.name === 'Component' || call.name === 'Directive') {
146
+ return call;
147
+ }
148
+ }
149
+
150
+ return null;
151
+ };
152
+
153
+ /**
154
+ * @param {string} memberName
155
+ * @param {string | null} text
156
+ */
157
+ const textReferencesMember = (memberName, text) => {
158
+ if (!text) return false;
159
+
160
+ const pattern = new RegExp(`\\b${escapeRegExp(memberName)}\\b`, 'u');
161
+ return pattern.test(text);
162
+ };
163
+
164
+ /**
165
+ * @param {string} memberName
166
+ * @param {any} metadata
167
+ * @param {import('eslint').Rule.RuleContext} context
168
+ */
169
+ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) => {
170
+ const templateProperty = getMetadataProperty(metadata, 'template');
171
+ if (templateProperty && templateReferencesMember(memberName, getStringValue(templateProperty.value))) {
172
+ return true;
173
+ }
174
+
175
+ const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
176
+ if (templateUrlProperty) {
177
+ const templateUrl = getStringValue(templateUrlProperty.value);
178
+ const filename = getContextFilename(context);
179
+
180
+ if (templateUrl && filename) {
181
+ const templatePath = path.resolve(path.dirname(filename), templateUrl);
182
+ const templateText = readFileIfExists(templatePath);
183
+ if (templateReferencesMember(memberName, templateText)) {
184
+ return true;
185
+ }
186
+ }
187
+ }
188
+
189
+ const hostProperty = getMetadataProperty(metadata, 'host');
190
+ if (hostProperty && hostProperty.value.type === 'ObjectExpression') {
191
+ for (const property of hostProperty.value.properties) {
192
+ if (!property || property.type !== 'Property') continue;
193
+ if (!isDynamicHostBindingKey(getPropertyKeyName(property))) continue;
194
+
195
+ if (textReferencesMember(memberName, getStringValue(property.value))) {
196
+ return true;
197
+ }
198
+ }
199
+ }
200
+
201
+ return false;
202
+ };
203
+
204
+ /**
205
+ * @param {any} node
206
+ */
207
+ const getMemberName = (node) => {
208
+ if (node.key?.type === 'Identifier') return node.key.name;
209
+ if (node.key?.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
210
+
211
+ return null;
212
+ };
213
+
214
+ /**
215
+ * @param {any} classNode
216
+ * @param {string} memberName
217
+ * @param {import('eslint').Rule.RuleContext} context
218
+ */
219
+ const isReferencedFromTemplateOrHost = (classNode, memberName, context) => {
220
+ const angularMetadata = getAngularMetadata(classNode);
221
+
222
+ if (!angularMetadata) {
223
+ return false;
224
+ }
225
+
226
+ return isReferencedFromTemplateOrHostMetadata(memberName, angularMetadata.metadata, context);
227
+ };
228
+
229
+ module.exports = {
230
+ getAngularMetadata,
231
+ getMemberName,
232
+ isReferencedFromTemplateOrHost,
233
+ };
@@ -0,0 +1,294 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const ts = require('typescript');
7
+
8
+ /** @type {Map<string, import('typescript').SourceFile | null>} */
9
+ const SOURCE_FILE_CACHE = new Map();
10
+
11
+ /**
12
+ * @param {string} filePath
13
+ */
14
+ const readFileIfExists = (filePath) => {
15
+ try {
16
+ if (fs.existsSync(filePath)) {
17
+ return fs.readFileSync(filePath, 'utf8');
18
+ }
19
+ } catch {
20
+ return null;
21
+ }
22
+
23
+ return null;
24
+ };
25
+
26
+ /**
27
+ * @param {{ filePath: string, text?: string | null }} config
28
+ */
29
+ const getSourceFile = ({ filePath, text = null }) => {
30
+ if (text !== null) {
31
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
32
+ }
33
+
34
+ if (SOURCE_FILE_CACHE.has(filePath)) {
35
+ return SOURCE_FILE_CACHE.get(filePath) ?? null;
36
+ }
37
+
38
+ const fileText = readFileIfExists(filePath);
39
+
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);
47
+ return sourceFile;
48
+ };
49
+
50
+ /**
51
+ * @param {string} importingFilePath
52
+ * @param {string} specifier
53
+ */
54
+ const resolveRelativeModule = (importingFilePath, specifier) => {
55
+ if (!specifier.startsWith('.')) {
56
+ return null;
57
+ }
58
+
59
+ const basePath = path.resolve(path.dirname(importingFilePath), specifier);
60
+ const candidates = [
61
+ `${basePath}.ts`,
62
+ `${basePath}.tsx`,
63
+ `${basePath}.d.ts`,
64
+ path.join(basePath, 'index.ts'),
65
+ path.join(basePath, 'index.d.ts'),
66
+ ];
67
+
68
+ for (const candidate of candidates) {
69
+ if (fs.existsSync(candidate)) {
70
+ return candidate;
71
+ }
72
+ }
73
+
74
+ return null;
75
+ };
76
+
77
+ /**
78
+ * @param {import('typescript').TypeElement | import('typescript').ClassElement} member
79
+ */
80
+ const getMemberName = (member) => {
81
+ if (!member.name) return null;
82
+
83
+ if (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) {
84
+ return member.name.text;
85
+ }
86
+
87
+ if (ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name)) {
88
+ return String(member.name.text);
89
+ }
90
+
91
+ return null;
92
+ };
93
+
94
+ /**
95
+ * @param {{ sourceFile: import('typescript').SourceFile, typeName: string, currentFilePath: string, visited: Set<string>, currentFileText?: string | null }} config
96
+ */
97
+ const collectMemberNamesForType = ({ sourceFile, typeName, currentFilePath, visited, currentFileText = null }) => {
98
+ const visitKey = `${sourceFile.fileName}::${typeName}`;
99
+ if (visited.has(visitKey)) {
100
+ return new Set();
101
+ }
102
+
103
+ visited.add(visitKey);
104
+
105
+ /** @type {Set<string>} */
106
+ const memberNames = new Set();
107
+
108
+ /**
109
+ * @param {import('typescript').TypeNode | undefined} typeNode
110
+ * @param {string} filePath
111
+ * @param {string | null} fileText
112
+ */
113
+ const collectFromTypeNode = (typeNode, filePath, fileText) => {
114
+ if (!typeNode) {
115
+ return;
116
+ }
117
+
118
+ if (ts.isTypeLiteralNode(typeNode)) {
119
+ for (const member of typeNode.members) {
120
+ const memberName = getMemberName(member);
121
+ if (memberName) memberNames.add(memberName);
122
+ }
123
+
124
+ return;
125
+ }
126
+
127
+ if (ts.isIntersectionTypeNode(typeNode)) {
128
+ for (const part of typeNode.types) {
129
+ collectFromTypeNode(part, filePath, fileText);
130
+ }
131
+
132
+ return;
133
+ }
134
+
135
+ if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) {
136
+ const referencedMembers = collectMemberNamesForTypeByName({
137
+ sourceFile: getSourceFile({ filePath, text: fileText }),
138
+ typeName: typeNode.typeName.text,
139
+ currentFilePath: filePath,
140
+ visited,
141
+ currentFileText: fileText,
142
+ });
143
+
144
+ for (const referencedMember of referencedMembers) {
145
+ memberNames.add(referencedMember);
146
+ }
147
+ }
148
+ };
149
+
150
+ const statements = sourceFile.statements;
151
+
152
+ for (const statement of statements) {
153
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
154
+ for (const member of statement.members) {
155
+ const memberName = getMemberName(member);
156
+ if (memberName) memberNames.add(memberName);
157
+ }
158
+
159
+ if (statement.heritageClauses) {
160
+ for (const clause of statement.heritageClauses) {
161
+ if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
162
+
163
+ for (const heritageType of clause.types) {
164
+ if (!ts.isIdentifier(heritageType.expression)) continue;
165
+
166
+ const inheritedMembers = collectMemberNamesForTypeByName({
167
+ sourceFile,
168
+ typeName: heritageType.expression.text,
169
+ currentFilePath,
170
+ visited,
171
+ currentFileText,
172
+ });
173
+
174
+ for (const inheritedMember of inheritedMembers) {
175
+ memberNames.add(inheritedMember);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ return memberNames;
182
+ }
183
+
184
+ if (ts.isTypeAliasDeclaration(statement) && statement.name.text === typeName) {
185
+ collectFromTypeNode(
186
+ statement.type,
187
+ sourceFile.fileName,
188
+ sourceFile.fileName === currentFilePath ? currentFileText : null,
189
+ );
190
+ return memberNames;
191
+ }
192
+ }
193
+
194
+ for (const statement of statements) {
195
+ if (!ts.isImportDeclaration(statement)) continue;
196
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
197
+ if (!statement.importClause || !statement.importClause.namedBindings) continue;
198
+ if (!ts.isNamedImports(statement.importClause.namedBindings)) continue;
199
+
200
+ for (const element of statement.importClause.namedBindings.elements) {
201
+ const localName = element.name.text;
202
+ if (localName !== typeName) continue;
203
+
204
+ const importedName = element.propertyName ? element.propertyName.text : element.name.text;
205
+ const resolvedModulePath = resolveRelativeModule(sourceFile.fileName, statement.moduleSpecifier.text);
206
+ if (!resolvedModulePath) {
207
+ return memberNames;
208
+ }
209
+
210
+ const importedSourceFile = getSourceFile({ filePath: resolvedModulePath });
211
+ if (!importedSourceFile) {
212
+ return memberNames;
213
+ }
214
+
215
+ const importedMembers = collectMemberNamesForTypeByName({
216
+ sourceFile: importedSourceFile,
217
+ typeName: importedName,
218
+ currentFilePath: resolvedModulePath,
219
+ visited,
220
+ });
221
+
222
+ for (const importedMember of importedMembers) {
223
+ memberNames.add(importedMember);
224
+ }
225
+
226
+ return memberNames;
227
+ }
228
+ }
229
+
230
+ return memberNames;
231
+ };
232
+
233
+ /**
234
+ * @param {{ sourceFile: import('typescript').SourceFile | null, typeName: string, currentFilePath: string, visited: Set<string>, currentFileText?: string | null }} config
235
+ */
236
+ const collectMemberNamesForTypeByName = ({
237
+ sourceFile,
238
+ typeName,
239
+ currentFilePath,
240
+ visited,
241
+ currentFileText = null,
242
+ }) => {
243
+ if (!sourceFile) {
244
+ return new Set();
245
+ }
246
+
247
+ return collectMemberNamesForType({
248
+ sourceFile,
249
+ typeName,
250
+ currentFilePath,
251
+ visited,
252
+ currentFileText,
253
+ });
254
+ };
255
+
256
+ /**
257
+ * @param {{ classNode: any, context: import('eslint').Rule.RuleContext }} config
258
+ */
259
+ const getImplementedContractMemberNames = ({ classNode, context }) => {
260
+ const filename = context.physicalFilename || context.filename;
261
+ if (!filename || filename === '<input>' || filename === '<text>') {
262
+ return new Set();
263
+ }
264
+
265
+ const currentSourceFile = getSourceFile({ filePath: filename, text: context.sourceCode.text });
266
+ if (!currentSourceFile) {
267
+ return new Set();
268
+ }
269
+
270
+ /** @type {Set<string>} */
271
+ const memberNames = new Set();
272
+
273
+ for (const implementedType of classNode.implements || []) {
274
+ if (!implementedType.expression || implementedType.expression.type !== 'Identifier') continue;
275
+
276
+ const typeMemberNames = collectMemberNamesForTypeByName({
277
+ sourceFile: currentSourceFile,
278
+ typeName: implementedType.expression.name,
279
+ currentFilePath: filename,
280
+ visited: new Set(),
281
+ currentFileText: context.sourceCode.text,
282
+ });
283
+
284
+ for (const memberName of typeMemberNames) {
285
+ memberNames.add(memberName);
286
+ }
287
+ }
288
+
289
+ return memberNames;
290
+ };
291
+
292
+ module.exports = {
293
+ getImplementedContractMemberNames,
294
+ };
@@ -0,0 +1,158 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows injecting Angular's ActivatedRoute (fully replaced),
6
+ * and disallows reading route state directly from an injected Router instance.
7
+ *
8
+ * inject(Router) is allowed — it is still needed for navigation (.navigate(),
9
+ * .navigateByUrl(), .createUrlTree(), etc.).
10
+ * Reading state properties off it is NOT allowed — use the @ethlete/core signal
11
+ * utilities instead.
12
+ *
13
+ * ❌ ActivatedRoute (fully replaced):
14
+ * inject(ActivatedRoute)
15
+ * import { ActivatedRoute } from '@angular/router';
16
+ *
17
+ * ❌ Router state property reads:
18
+ * this.router.url → injectUrl()
19
+ * this.router.events → injectRouterEvent()
20
+ * this.router.routerState → injectRouterState()
21
+ * this.router.snapshot → injectRouterState()
22
+ * this.router.lastSuccessfulNavigation → injectRouterState()
23
+ * this.router.currentNavigation → injectRouterState()
24
+ *
25
+ * ✅ inject(Router) is still fine for navigation:
26
+ * router.navigate(['/path'])
27
+ * router.navigateByUrl('/path')
28
+ * router.createUrlTree(['/path'])
29
+ *
30
+ * ✅ Use the @ethlete/core router utilities for state:
31
+ * injectUrl(), injectRoute(), injectRouterEvent(), injectRouterState(),
32
+ * injectIsRouterInitialized(), injectQueryParam(key), injectPathParam(key),
33
+ * injectQueryParams(), injectPathParams(), injectFragment(),
34
+ * injectRouteData(), injectRouteDataItem(key), injectRouteTitle(),
35
+ * injectQueryParamChanges(), injectPathParamChanges()
36
+ */
37
+
38
+ /**
39
+ * Router properties that expose route state and must not be read directly.
40
+ * @type {Map<string, string>}
41
+ */
42
+ const ROUTER_STATE_PROPS = new Map([
43
+ ['url', "injectUrl() from '@ethlete/core'"],
44
+ ['events', "injectRouterEvent() from '@ethlete/core'"],
45
+ ['routerState', "injectRouterState() from '@ethlete/core'"],
46
+ ['snapshot', "injectRouterState() from '@ethlete/core'"],
47
+ ['lastSuccessfulNavigation', "injectRouterState() from '@ethlete/core'"],
48
+ ['currentNavigation', "injectRouterState() from '@ethlete/core'"],
49
+ ]);
50
+
51
+ /** @type {import('eslint').Rule.RuleModule} */
52
+ const noAngularRouterApi = {
53
+ meta: {
54
+ type: 'suggestion',
55
+ docs: {
56
+ description:
57
+ "Disallow injecting 'ActivatedRoute' (fully replaced) and reading state properties off an injected 'Router'.",
58
+ recommended: true,
59
+ },
60
+ messages: {
61
+ noActivatedRoute:
62
+ "Do not inject 'ActivatedRoute'. Use reactive signal utilities from '@ethlete/core' instead: 'injectQueryParam(key)', 'injectPathParam(key)', 'injectQueryParams()', 'injectPathParams()', 'injectFragment()', 'injectRouteData()', 'injectRouteDataItem(key)', or 'injectRouteTitle()'. All return reactive signals and are SSR-safe.",
63
+ noRouterStateProp:
64
+ "Do not read 'router.{{prop}}' directly. Use {{replacement}} instead — it returns a reactive signal and is SSR-safe. Use inject(Router) only for navigation (.navigate(), .navigateByUrl()).",
65
+ },
66
+ schema: [],
67
+ },
68
+ create(context) {
69
+ /**
70
+ * Names of variables/properties assigned from inject(Router).
71
+ * We track identifiers whose initializer is inject(Router) so we can
72
+ * detect property access on them anywhere in the file.
73
+ * @type {Set<string>}
74
+ */
75
+ const routerBindings = new Set();
76
+
77
+ return {
78
+ // ── Track inject(Router) bindings ──────────────────────────────────────
79
+ // const router = inject(Router);
80
+ 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
+ ) {
89
+ routerBindings.add(node.id.name);
90
+ }
91
+ },
92
+
93
+ // ── Track class property assignments: router = inject(Router) ──────────
94
+ 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
+ routerBindings.add(node.key.name);
104
+ }
105
+ },
106
+
107
+ // ── Detect router.{stateProp} access ───────────────────────────────────
108
+ MemberExpression(node) {
109
+ if (node.property.type !== 'Identifier') return;
110
+ const prop = node.property.name;
111
+ if (!ROUTER_STATE_PROPS.has(prop)) return;
112
+
113
+ // Check object is a known router binding (direct: router.url)
114
+ // or a this.router.url / self.router.url pattern
115
+ const obj = node.object;
116
+ const isDirectBinding = obj.type === 'Identifier' && routerBindings.has(obj.name);
117
+ const isThisBinding =
118
+ obj.type === 'MemberExpression' &&
119
+ obj.object.type === 'ThisExpression' &&
120
+ obj.property.type === 'Identifier' &&
121
+ routerBindings.has(obj.property.name);
122
+
123
+ if (!isDirectBinding && !isThisBinding) return;
124
+
125
+ context.report({
126
+ node,
127
+ messageId: 'noRouterStateProp',
128
+ data: { prop, replacement: ROUTER_STATE_PROPS.get(prop) },
129
+ });
130
+ },
131
+
132
+ // ── 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
+ }
141
+ }
142
+ },
143
+
144
+ // ── inject(ActivatedRoute) ──────────────────────────────────────────────
145
+ 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') {
151
+ context.report({ node, messageId: 'noActivatedRoute' });
152
+ }
153
+ },
154
+ };
155
+ },
156
+ };
157
+
158
+ module.exports = noAngularRouterApi;