@ethlete/eslint-plugin 0.1.0-beta.5
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.
- package/CHANGELOG.md +39 -0
- package/README.md +25 -0
- package/package.json +14 -0
- package/src/configs/recommended.js +403 -0
- package/src/index.js +136 -0
- package/src/rules/angular-decorator-property-order.js +205 -0
- package/src/rules/class-constant-property.js +344 -0
- package/src/rules/class-member-order.js +431 -0
- package/src/rules/enforce-routing-view-naming.js +84 -0
- package/src/rules/guard-return-newline.js +81 -0
- package/src/rules/inject-member-accessibility.js +93 -0
- package/src/rules/internals/angular-member-visibility.js +233 -0
- package/src/rules/internals/implemented-contract-members.js +294 -0
- package/src/rules/no-angular-router-api.js +158 -0
- package/src/rules/no-angular-seo-services.js +82 -0
- package/src/rules/no-direct-dom-manipulation.js +160 -0
- package/src/rules/no-document-cookie.js +55 -0
- package/src/rules/no-dom-query.js +81 -0
- package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
- package/src/rules/no-empty-newlines-between-imports.js +45 -0
- package/src/rules/no-inject-chain.js +44 -0
- package/src/rules/no-leading-underscore-class-member.js +120 -0
- package/src/rules/no-legacy-angular-decorators.js +405 -0
- package/src/rules/no-locale-id.js +57 -0
- package/src/rules/no-member-alias.js +151 -0
- package/src/rules/no-native-observers.js +102 -0
- package/src/rules/no-pipe-logic.js +76 -0
- package/src/rules/no-public-property.js +85 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-redundant-internal.js +94 -0
- package/src/rules/no-rxjs-in-effect.js +88 -0
- package/src/rules/no-screaming-case-local.js +113 -0
- package/src/rules/no-standalone-flag.js +95 -0
- package/src/rules/no-subscribe-in-pipe.js +89 -0
- package/src/rules/no-subscribe-with-body.js +95 -0
- package/src/rules/no-trivial-return-type.js +104 -0
- package/src/rules/no-trivial-wrapper-method.js +120 -0
- package/src/rules/no-type-only-import.js +79 -0
- package/src/rules/no-typed-injected-element-ref.js +88 -0
- package/src/rules/no-unused-class-member.js +209 -0
- package/src/rules/no-window-location.js +109 -0
- package/src/rules/prefer-clone-equal.js +112 -0
- package/src/rules/prefer-concise-angular-host-directives.js +225 -0
- package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
- package/src/rules/prefer-element-dimensions.js +128 -0
- package/src/rules/prefer-linked-signal.js +124 -0
- package/src/rules/prefer-match-media.js +101 -0
- package/src/rules/prefer-rxjs-timer.js +82 -0
- package/src/rules/prefer-scroll-state.js +158 -0
- package/src/rules/prefer-viewport-size.js +75 -0
- package/src/rules/require-dollar-suffix.js +123 -0
- package/src/rules/require-on-push-change-detection.js +271 -0
- package/src/rules/require-view-encapsulation-none.js +264 -0
- package/src/rules/template-member-accessibility.js +190 -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;
|