@benjavicente/lint-angular 0.0.1 → 0.0.3
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/README.md +20 -13
- package/dist/index.mjs +1554 -258
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -23,6 +23,194 @@ function getRange(node) {
|
|
|
23
23
|
if (typeof node.start === "number" && typeof node.end === "number") return [node.start, node.end];
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
|
+
function getNodeStart(node) {
|
|
27
|
+
if (typeof node.start === "number") return node.start;
|
|
28
|
+
if (Array.isArray(node.range) && typeof node.range[0] === "number") return node.range[0];
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function getTypeName(node) {
|
|
32
|
+
if (!node) return null;
|
|
33
|
+
if (node.type === "Identifier") return node.name;
|
|
34
|
+
if (node.type === "TSTypeReference") return getTypeName(node.typeName);
|
|
35
|
+
if (node.type === "TSQualifiedName") {
|
|
36
|
+
const left = getTypeName(node.left);
|
|
37
|
+
const right = getTypeName(node.right);
|
|
38
|
+
return left && right ? `${left}.${right}` : right ?? left;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const TRANSPARENT_EXPRESSION_NODE_TYPES = new Set([
|
|
43
|
+
"ChainExpression",
|
|
44
|
+
"ParenthesizedExpression",
|
|
45
|
+
"TSAsExpression",
|
|
46
|
+
"TSInstantiationExpression",
|
|
47
|
+
"TSNonNullExpression",
|
|
48
|
+
"TSSatisfiesExpression",
|
|
49
|
+
"TSTypeAssertion"
|
|
50
|
+
]);
|
|
51
|
+
function unwrapExpression(node) {
|
|
52
|
+
let current = node;
|
|
53
|
+
while (current && TRANSPARENT_EXPRESSION_NODE_TYPES.has(current.type)) current = current.expression ?? current.argument ?? current.object ?? current.callee ?? current.typeAnnotation ?? null;
|
|
54
|
+
return current ?? null;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/utilities/scope.ts
|
|
58
|
+
const FUNCTION_TYPES$1 = new Set([
|
|
59
|
+
"ArrowFunctionExpression",
|
|
60
|
+
"FunctionDeclaration",
|
|
61
|
+
"FunctionExpression"
|
|
62
|
+
]);
|
|
63
|
+
function isFunction$1(node) {
|
|
64
|
+
return !!node && FUNCTION_TYPES$1.has(node.type);
|
|
65
|
+
}
|
|
66
|
+
function addBindingIdentifierNodes(node, identifiers) {
|
|
67
|
+
if (!node) return;
|
|
68
|
+
if (node.type === "Identifier") {
|
|
69
|
+
identifiers.push(node);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (node.type === "AssignmentPattern") {
|
|
73
|
+
addBindingIdentifierNodes(node.left, identifiers);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (node.type === "RestElement" || node.type === "TSParameterProperty") {
|
|
77
|
+
addBindingIdentifierNodes(node.argument ?? node.parameter, identifiers);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (node.type === "ArrayPattern") {
|
|
81
|
+
for (const element of node.elements ?? []) addBindingIdentifierNodes(element, identifiers);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (node.type === "ObjectPattern") for (const property of node.properties ?? []) if (property.type === "Property") addBindingIdentifierNodes(property.value, identifiers);
|
|
85
|
+
else addBindingIdentifierNodes(property.argument, identifiers);
|
|
86
|
+
}
|
|
87
|
+
function getBindingIdentifierNodes(node) {
|
|
88
|
+
const identifiers = [];
|
|
89
|
+
addBindingIdentifierNodes(node, identifiers);
|
|
90
|
+
return identifiers;
|
|
91
|
+
}
|
|
92
|
+
function getBindingNames(node) {
|
|
93
|
+
return new Set(getBindingIdentifierNodes(node).map((identifier) => identifier.name));
|
|
94
|
+
}
|
|
95
|
+
function hasBindingName(node, name) {
|
|
96
|
+
return getBindingNames(node).has(name);
|
|
97
|
+
}
|
|
98
|
+
function getMatchingBindingIdentifier(node, name) {
|
|
99
|
+
return getBindingIdentifierNodes(node).find((identifier) => identifier.name === name) ?? null;
|
|
100
|
+
}
|
|
101
|
+
function isBeforeReference(node, reference) {
|
|
102
|
+
const nodeStart = getNodeStart(node);
|
|
103
|
+
const referenceStart = getNodeStart(reference);
|
|
104
|
+
return nodeStart !== null && referenceStart !== null && nodeStart < referenceStart;
|
|
105
|
+
}
|
|
106
|
+
function hasDeclarationBeforeReference(node, name, reference) {
|
|
107
|
+
if (!node) return false;
|
|
108
|
+
if (Array.isArray(node)) return node.some((child) => hasDeclarationBeforeReference(child, name, reference));
|
|
109
|
+
if (!isBeforeReference(node, reference)) return false;
|
|
110
|
+
if (node.type === "VariableDeclarator") return hasBindingName(node.id, name);
|
|
111
|
+
if ((node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && node.id?.name === name) return true;
|
|
112
|
+
if (node !== reference && isFunction$1(node)) return false;
|
|
113
|
+
if (node !== reference && (node.type === "ClassDeclaration" || node.type === "ClassExpression")) return false;
|
|
114
|
+
for (const [key, value] of Object.entries(node)) {
|
|
115
|
+
if (key === "parent") continue;
|
|
116
|
+
if (!value || typeof value !== "object") continue;
|
|
117
|
+
if (hasDeclarationBeforeReference(value, name, reference)) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function findDeclarationBeforeReference(node, name, reference) {
|
|
122
|
+
if (!node) return null;
|
|
123
|
+
if (Array.isArray(node)) {
|
|
124
|
+
for (const child of node) {
|
|
125
|
+
const declaration = findDeclarationBeforeReference(child, name, reference);
|
|
126
|
+
if (declaration) return declaration;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!isBeforeReference(node, reference)) return null;
|
|
131
|
+
if (node.type === "VariableDeclarator") return getMatchingBindingIdentifier(node.id, name);
|
|
132
|
+
if ((node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && node.id?.name === name) return node.id;
|
|
133
|
+
if (node !== reference && isFunction$1(node)) return null;
|
|
134
|
+
if (node !== reference && (node.type === "ClassDeclaration" || node.type === "ClassExpression")) return null;
|
|
135
|
+
for (const [key, value] of Object.entries(node)) {
|
|
136
|
+
if (key === "parent") continue;
|
|
137
|
+
if (!value || typeof value !== "object") continue;
|
|
138
|
+
const declaration = findDeclarationBeforeReference(value, name, reference);
|
|
139
|
+
if (declaration) return declaration;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
function findNearestBindingIdentifier(context, node) {
|
|
144
|
+
if (node?.type !== "Identifier") return null;
|
|
145
|
+
const ancestors = context.sourceCode.getAncestors(node);
|
|
146
|
+
for (const ancestor of ancestors.toReversed()) {
|
|
147
|
+
if (isFunction$1(ancestor)) for (const param of ancestor.params ?? []) {
|
|
148
|
+
const binding = getMatchingBindingIdentifier(param, node.name);
|
|
149
|
+
if (binding) return binding;
|
|
150
|
+
}
|
|
151
|
+
if (ancestor.type === "CatchClause") {
|
|
152
|
+
const binding = getMatchingBindingIdentifier(ancestor.param, node.name);
|
|
153
|
+
if (binding) return binding;
|
|
154
|
+
}
|
|
155
|
+
if (ancestor.type === "BlockStatement" || ancestor.type === "Program") {
|
|
156
|
+
const binding = findDeclarationBeforeReference(ancestor.body, node.name, node);
|
|
157
|
+
if (binding) return binding;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function isShadowedIdentifier(context, node) {
|
|
163
|
+
if (node?.type !== "Identifier") return false;
|
|
164
|
+
const ancestors = context.sourceCode.getAncestors(node);
|
|
165
|
+
for (const ancestor of ancestors.toReversed()) {
|
|
166
|
+
if (isFunction$1(ancestor) && (ancestor.params ?? []).some((param) => hasBindingName(param, node.name))) return true;
|
|
167
|
+
if (ancestor.type === "CatchClause" && hasBindingName(ancestor.param, node.name)) return true;
|
|
168
|
+
if ((ancestor.type === "BlockStatement" || ancestor.type === "Program") && hasDeclarationBeforeReference(ancestor.body, node.name, node)) return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/utilities/angular.ts
|
|
174
|
+
function getProgramNode(context, node) {
|
|
175
|
+
if (node.type === "Program") return node;
|
|
176
|
+
return context.sourceCode.getAncestors(node).find((ancestor) => ancestor.type === "Program") ?? null;
|
|
177
|
+
}
|
|
178
|
+
function getImportedName(context, node, source) {
|
|
179
|
+
if (node?.type !== "Identifier") return null;
|
|
180
|
+
if (isShadowedIdentifier(context, node)) return null;
|
|
181
|
+
const program = getProgramNode(context, node);
|
|
182
|
+
for (const statement of program?.body ?? []) {
|
|
183
|
+
if (statement.type !== "ImportDeclaration" || statement.source?.value !== source) continue;
|
|
184
|
+
for (const specifier of statement.specifiers ?? []) {
|
|
185
|
+
if (specifier.type !== "ImportSpecifier" || specifier.local.name !== node.name) continue;
|
|
186
|
+
return getPropertyName(specifier.imported);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
function isNamespaceImport(context, node, source) {
|
|
192
|
+
if (node?.type !== "Identifier") return false;
|
|
193
|
+
if (isShadowedIdentifier(context, node)) return false;
|
|
194
|
+
const program = getProgramNode(context, node);
|
|
195
|
+
for (const statement of program?.body ?? []) {
|
|
196
|
+
if (statement.type !== "ImportDeclaration" || statement.source?.value !== source) continue;
|
|
197
|
+
if ((statement.specifiers ?? []).some((specifier) => specifier.type === "ImportNamespaceSpecifier" && specifier.local.name === node.name)) return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
function isImportedReference(context, node, source, importedNames) {
|
|
202
|
+
const importedName = getImportedName(context, node, source);
|
|
203
|
+
return !!importedName && importedNames.has(importedName);
|
|
204
|
+
}
|
|
205
|
+
function isImportedNamespaceMember(context, node, source, memberNames) {
|
|
206
|
+
return node?.type === "MemberExpression" && node.object?.type === "Identifier" && isNamespaceImport(context, node.object, source) && memberNames.has(getPropertyName(node.property) ?? "");
|
|
207
|
+
}
|
|
208
|
+
function isAngularCoreDecorator(context, decorator, decoratorNames) {
|
|
209
|
+
if (!decorator) return false;
|
|
210
|
+
const expression = decorator.expression ?? decorator;
|
|
211
|
+
const callee = expression?.type === "CallExpression" ? expression.callee : expression;
|
|
212
|
+
return isImportedReference(context, callee, "@angular/core", decoratorNames) || isImportedNamespaceMember(context, callee, "@angular/core", decoratorNames);
|
|
213
|
+
}
|
|
26
214
|
//#endregion
|
|
27
215
|
//#region src/rules/class-member-order/index.ts
|
|
28
216
|
const ANGULAR_CLASS_DECORATOR_NAMES$1 = new Set([
|
|
@@ -45,38 +233,136 @@ const ORDER_LABELS = [
|
|
|
45
233
|
"outputs",
|
|
46
234
|
"everything else"
|
|
47
235
|
];
|
|
48
|
-
function hasAngularClassDecorator(classNode) {
|
|
236
|
+
function hasAngularClassDecorator(context, classNode) {
|
|
49
237
|
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
50
|
-
return classNode.decorators.some((decorator) => ANGULAR_CLASS_DECORATOR_NAMES$1
|
|
238
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, ANGULAR_CLASS_DECORATOR_NAMES$1));
|
|
51
239
|
}
|
|
52
|
-
function
|
|
53
|
-
if (!node || node.type !== "CallExpression") return
|
|
240
|
+
function isApiCall(context, node, apiNames) {
|
|
241
|
+
if (!node || node.type !== "CallExpression") return false;
|
|
54
242
|
const callee = node.callee;
|
|
55
|
-
if (callee
|
|
56
|
-
if (callee?.type
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
243
|
+
if (isImportedReference(context, callee, "@angular/core", apiNames)) return true;
|
|
244
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
245
|
+
const supportsRequiredApi = [...INPUT_MODEL_CALL_NAMES].some((name) => apiNames.has(name));
|
|
246
|
+
if (callee.object?.type === "Identifier") {
|
|
247
|
+
if (getPropertyName(callee.property) === "required") return supportsRequiredApi && isImportedReference(context, callee.object, "@angular/core", apiNames);
|
|
248
|
+
return isImportedNamespaceMember(context, callee, "@angular/core", apiNames);
|
|
249
|
+
}
|
|
250
|
+
if (getPropertyName(callee.property) === "required" && callee.object?.type === "MemberExpression" && callee.object.object?.type === "Identifier") return supportsRequiredApi && isImportedNamespaceMember(context, callee.object, "@angular/core", apiNames) && apiNames.has(getPropertyName(callee.object.property) ?? "");
|
|
251
|
+
return false;
|
|
61
252
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
const callee = value.callee;
|
|
65
|
-
return callee?.type === "Identifier" && callee.name === "inject" || callee?.type === "MemberExpression" && getPropertyName(callee.property) === "inject";
|
|
253
|
+
function hasDecorator(context, element, decoratorNames) {
|
|
254
|
+
return Array.isArray(element.decorators) ? element.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, decoratorNames)) : false;
|
|
66
255
|
}
|
|
67
|
-
function classifyMember(element) {
|
|
256
|
+
function classifyMember(context, element) {
|
|
68
257
|
if (CLASS_FIELD_TYPES$1.has(element.type)) {
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
if (
|
|
72
|
-
if (
|
|
73
|
-
if (
|
|
74
|
-
if (hasDecorator(element, new Set(["Output"]))) return 2;
|
|
258
|
+
if (isApiCall(context, element.value, new Set(["inject"]))) return 0;
|
|
259
|
+
if (isApiCall(context, element.value, INPUT_MODEL_CALL_NAMES)) return 1;
|
|
260
|
+
if (hasDecorator(context, element, new Set(["Input"]))) return 1;
|
|
261
|
+
if (isApiCall(context, element.value, OUTPUT_CALL_NAMES)) return 2;
|
|
262
|
+
if (hasDecorator(context, element, new Set(["Output"]))) return 2;
|
|
75
263
|
return 3;
|
|
76
264
|
}
|
|
77
265
|
if (element.type === "MethodDefinition") return 3;
|
|
78
266
|
return 3;
|
|
79
267
|
}
|
|
268
|
+
function getMemberName$1(node) {
|
|
269
|
+
if (!node) return null;
|
|
270
|
+
if (node.type === "Identifier" || node.type === "PrivateIdentifier") return node.name;
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
function collectThisMemberReferences(node, references = /* @__PURE__ */ new Set()) {
|
|
274
|
+
if (!node) return references;
|
|
275
|
+
if (Array.isArray(node)) {
|
|
276
|
+
for (const item of node) collectThisMemberReferences(item, references);
|
|
277
|
+
return references;
|
|
278
|
+
}
|
|
279
|
+
if (node.type === "MemberExpression" && node.object?.type === "ThisExpression") {
|
|
280
|
+
const propertyName = getPropertyName(node.property);
|
|
281
|
+
if (propertyName) references.add(propertyName);
|
|
282
|
+
}
|
|
283
|
+
for (const [key, value] of Object.entries(node)) {
|
|
284
|
+
if (key === "parent") continue;
|
|
285
|
+
if (!value || typeof value !== "object") continue;
|
|
286
|
+
collectThisMemberReferences(value, references);
|
|
287
|
+
}
|
|
288
|
+
return references;
|
|
289
|
+
}
|
|
290
|
+
function applyDependencyGroups(classifiedMembers) {
|
|
291
|
+
const membersByName = /* @__PURE__ */ new Map();
|
|
292
|
+
for (const member of classifiedMembers) if (member.name) membersByName.set(member.name, member);
|
|
293
|
+
let changed = true;
|
|
294
|
+
while (changed) {
|
|
295
|
+
changed = false;
|
|
296
|
+
for (const member of classifiedMembers) for (const dependencyName of member.dependencies) {
|
|
297
|
+
const dependency = membersByName.get(dependencyName);
|
|
298
|
+
if (!dependency) continue;
|
|
299
|
+
if (dependency.effectiveGroup <= member.effectiveGroup) continue;
|
|
300
|
+
dependency.effectiveGroup = member.effectiveGroup;
|
|
301
|
+
changed = true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function dependsOn(member, dependency, membersByName, seen = /* @__PURE__ */ new Set()) {
|
|
306
|
+
if (!dependency.name) return false;
|
|
307
|
+
if (member.dependencies.has(dependency.name)) return true;
|
|
308
|
+
if (seen.has(member)) return false;
|
|
309
|
+
seen.add(member);
|
|
310
|
+
for (const dependencyName of member.dependencies) {
|
|
311
|
+
const next = membersByName.get(dependencyName);
|
|
312
|
+
if (next && dependsOn(next, dependency, membersByName, seen)) return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
function getSortedMembers(classifiedMembers) {
|
|
317
|
+
const membersByName = /* @__PURE__ */ new Map();
|
|
318
|
+
for (const member of classifiedMembers) if (member.name) membersByName.set(member.name, member);
|
|
319
|
+
return classifiedMembers.toSorted((left, right) => {
|
|
320
|
+
if (dependsOn(left, right, membersByName)) return 1;
|
|
321
|
+
if (dependsOn(right, left, membersByName)) return -1;
|
|
322
|
+
return left.effectiveGroup - right.effectiveGroup;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function hasUnresolvedPrioritizedDependency(member, membersByName) {
|
|
326
|
+
if (member.group === 3) return false;
|
|
327
|
+
for (const dependencyName of member.dependencies) if (!membersByName.has(dependencyName)) return true;
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
function getSortedMembersFix(context, classBody, classifiedMembers) {
|
|
331
|
+
if (!classifiedMembers.length) return void 0;
|
|
332
|
+
if ((classBody.body ?? []).length !== classifiedMembers.length) return void 0;
|
|
333
|
+
const sortableRanges = [];
|
|
334
|
+
const membersByName = /* @__PURE__ */ new Map();
|
|
335
|
+
for (const member of classifiedMembers) if (member.name) membersByName.set(member.name, member);
|
|
336
|
+
for (const member of classifiedMembers) {
|
|
337
|
+
const { element } = member;
|
|
338
|
+
if (!CLASS_FIELD_TYPES$1.has(element.type)) return void 0;
|
|
339
|
+
if (element.type === "AccessorProperty") return void 0;
|
|
340
|
+
if (element.computed) return void 0;
|
|
341
|
+
if (Array.isArray(element.decorators) && element.decorators.length > 0) return void 0;
|
|
342
|
+
if (hasUnresolvedPrioritizedDependency(member, membersByName)) return;
|
|
343
|
+
const range = getRange(element);
|
|
344
|
+
if (!range) return void 0;
|
|
345
|
+
if (!context.sourceCode.text.slice(range[0], range[1]).trimEnd().endsWith(";")) return;
|
|
346
|
+
sortableRanges.push(range);
|
|
347
|
+
}
|
|
348
|
+
const sortedMembers = getSortedMembers(classifiedMembers);
|
|
349
|
+
if (sortedMembers.every((member, index) => member.element === classifiedMembers[index]?.element)) return void 0;
|
|
350
|
+
return (fixer) => {
|
|
351
|
+
const sourceText = context.sourceCode.text;
|
|
352
|
+
const sortedTexts = sortedMembers.map(({ element }) => {
|
|
353
|
+
const range = getRange(element);
|
|
354
|
+
return range ? sourceText.slice(range[0], range[1]) : "";
|
|
355
|
+
});
|
|
356
|
+
let output = "";
|
|
357
|
+
for (let index = 0; index < sortableRanges.length; index += 1) {
|
|
358
|
+
const [, end] = sortableRanges[index];
|
|
359
|
+
const nextStart = sortableRanges[index + 1]?.[0];
|
|
360
|
+
output += sortedTexts[index];
|
|
361
|
+
output += sourceText.slice(end, nextStart ?? end);
|
|
362
|
+
}
|
|
363
|
+
return fixer.replaceTextRange([sortableRanges[0][0], sortableRanges.at(-1)[1]], output);
|
|
364
|
+
};
|
|
365
|
+
}
|
|
80
366
|
const classMemberOrder = defineRule({
|
|
81
367
|
meta: {
|
|
82
368
|
type: "suggestion",
|
|
@@ -84,29 +370,51 @@ const classMemberOrder = defineRule({
|
|
|
84
370
|
description: "Require Angular class members to be ordered as inject fields, inputs/models, outputs, then everything else.",
|
|
85
371
|
recommended: true
|
|
86
372
|
},
|
|
373
|
+
fixable: "code",
|
|
87
374
|
schema: [],
|
|
88
375
|
messages: { outOfOrder: "Angular class member should be ordered before {{previousGroup}} and with {{expectedGroup}}." }
|
|
89
376
|
},
|
|
90
377
|
createOnce(context) {
|
|
91
378
|
return { ClassBody(node) {
|
|
92
|
-
|
|
379
|
+
const classBody = node;
|
|
380
|
+
if (!hasAngularClassDecorator(context, classBody.parent)) return;
|
|
93
381
|
let highestSeen = null;
|
|
94
|
-
|
|
95
|
-
|
|
382
|
+
const classifiedMembers = [];
|
|
383
|
+
const outOfOrderReports = [];
|
|
384
|
+
for (const element of classBody.body ?? []) {
|
|
385
|
+
const group = classifyMember(context, element);
|
|
96
386
|
if (group === null) continue;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
387
|
+
classifiedMembers.push({
|
|
388
|
+
element,
|
|
389
|
+
group,
|
|
390
|
+
effectiveGroup: group,
|
|
391
|
+
name: getMemberName$1(element.key),
|
|
392
|
+
dependencies: collectThisMemberReferences(element.value)
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
applyDependencyGroups(classifiedMembers);
|
|
396
|
+
for (const { element, effectiveGroup } of classifiedMembers) {
|
|
397
|
+
if (highestSeen !== null && effectiveGroup < highestSeen) {
|
|
398
|
+
outOfOrderReports.push({
|
|
399
|
+
element,
|
|
400
|
+
effectiveGroup,
|
|
401
|
+
previousEffectiveGroup: highestSeen
|
|
105
402
|
});
|
|
106
403
|
continue;
|
|
107
404
|
}
|
|
108
|
-
highestSeen =
|
|
405
|
+
highestSeen = effectiveGroup;
|
|
109
406
|
}
|
|
407
|
+
if (!outOfOrderReports.length) return;
|
|
408
|
+
const fix = getSortedMembersFix(context, classBody, classifiedMembers);
|
|
409
|
+
for (const [index, report] of outOfOrderReports.entries()) context.report({
|
|
410
|
+
node: report.element,
|
|
411
|
+
messageId: "outOfOrder",
|
|
412
|
+
data: {
|
|
413
|
+
expectedGroup: ORDER_LABELS[report.effectiveGroup],
|
|
414
|
+
previousGroup: ORDER_LABELS[report.previousEffectiveGroup]
|
|
415
|
+
},
|
|
416
|
+
fix: index === 0 ? fix : void 0
|
|
417
|
+
});
|
|
110
418
|
} };
|
|
111
419
|
}
|
|
112
420
|
});
|
|
@@ -115,25 +423,22 @@ const classMemberOrder = defineRule({
|
|
|
115
423
|
const DEFAULT_DISALLOW_INJECT_INJECTOR = true;
|
|
116
424
|
const DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT = true;
|
|
117
425
|
const RUN_IN_INJECTION_CONTEXT_NAMES = new Set(["runInInjectionContext", "runInContext"]);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
function isInjectCall(callNode, injectNames, angularNamespaces) {
|
|
426
|
+
const INJECT_NAMES = new Set(["inject"]);
|
|
427
|
+
const INJECTOR_NAMES = new Set(["Injector"]);
|
|
428
|
+
function isInjectCall(context, callNode) {
|
|
122
429
|
const callee = callNode.callee;
|
|
123
|
-
return callee
|
|
430
|
+
return isImportedReference(context, callee, "@angular/core", INJECT_NAMES) || isImportedNamespaceMember(context, callee, "@angular/core", INJECT_NAMES);
|
|
124
431
|
}
|
|
125
|
-
function isInjectorReference(
|
|
126
|
-
return node
|
|
432
|
+
function isInjectorReference(context, node) {
|
|
433
|
+
return isImportedReference(context, node, "@angular/core", INJECTOR_NAMES) || isImportedNamespaceMember(context, node, "@angular/core", INJECTOR_NAMES);
|
|
127
434
|
}
|
|
128
|
-
function isDisallowedInjectInjector(
|
|
129
|
-
if (!isInjectCall(
|
|
130
|
-
return isInjectorReference(callNode.arguments?.[0]
|
|
435
|
+
function isDisallowedInjectInjector(context, callNode) {
|
|
436
|
+
if (!isInjectCall(context, callNode)) return false;
|
|
437
|
+
return isInjectorReference(context, callNode.arguments?.[0]);
|
|
131
438
|
}
|
|
132
|
-
function isDisallowedRunInInjectionContext(
|
|
439
|
+
function isDisallowedRunInInjectionContext(context, callNode) {
|
|
133
440
|
const callee = callNode.callee;
|
|
134
|
-
|
|
135
|
-
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name)) return RUN_IN_INJECTION_CONTEXT_NAMES.has(getPropertyName(callee.property) ?? "");
|
|
136
|
-
return false;
|
|
441
|
+
return isImportedReference(context, callee, "@angular/core", RUN_IN_INJECTION_CONTEXT_NAMES) || isImportedNamespaceMember(context, callee, "@angular/core", RUN_IN_INJECTION_CONTEXT_NAMES);
|
|
137
442
|
}
|
|
138
443
|
const avoidExplicitInjectionContext = defineRule({
|
|
139
444
|
meta: {
|
|
@@ -162,44 +467,378 @@ const avoidExplicitInjectionContext = defineRule({
|
|
|
162
467
|
}
|
|
163
468
|
},
|
|
164
469
|
createOnce(context) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
470
|
+
return { CallExpression(node) {
|
|
471
|
+
const callNode = node;
|
|
472
|
+
const options = context.options[0] ?? {};
|
|
473
|
+
const disallowInjectInjector = options.disallowInjectInjector ?? DEFAULT_DISALLOW_INJECT_INJECTOR;
|
|
474
|
+
const disallowRunInInjectionContext = options.disallowRunInInjectionContext ?? DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT;
|
|
475
|
+
if (disallowInjectInjector && isDisallowedInjectInjector(context, callNode)) context.report({
|
|
476
|
+
node: callNode.callee,
|
|
477
|
+
messageId: "avoidInjectInjector"
|
|
478
|
+
});
|
|
479
|
+
if (disallowRunInInjectionContext && isDisallowedRunInInjectionContext(context, callNode)) context.report({
|
|
480
|
+
node: callNode.callee,
|
|
481
|
+
messageId: "avoidRunInInjectionContext"
|
|
482
|
+
});
|
|
483
|
+
} };
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/rules/avoid-explicit-subscription-management/index.ts
|
|
488
|
+
const TARGET_DECORATORS$2 = new Set([
|
|
489
|
+
"Component",
|
|
490
|
+
"Directive",
|
|
491
|
+
"Injectable"
|
|
492
|
+
]);
|
|
493
|
+
const FIELD_NODE_TYPES$2 = new Set([
|
|
494
|
+
"AccessorProperty",
|
|
495
|
+
"FieldDefinition",
|
|
496
|
+
"PropertyDefinition"
|
|
497
|
+
]);
|
|
498
|
+
const RXJS_SUBSCRIBABLE_NAMES = new Set([
|
|
499
|
+
"AsyncSubject",
|
|
500
|
+
"BehaviorSubject",
|
|
501
|
+
"Observable",
|
|
502
|
+
"ReplaySubject",
|
|
503
|
+
"Subject"
|
|
504
|
+
]);
|
|
505
|
+
function hasTargetDecorator$3(context, classNode) {
|
|
506
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
507
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS$2));
|
|
508
|
+
}
|
|
509
|
+
function hasSubscriptionTypeReference(context, node, subscriptionLocalNames, rxjsNamespaces) {
|
|
510
|
+
if (!node) return false;
|
|
511
|
+
if (node.type === "TSTypeReference") {
|
|
512
|
+
const typeName = getTypeName(node.typeName);
|
|
513
|
+
if (node.typeName?.type === "Identifier" && typeName && subscriptionLocalNames.has(typeName) && !isShadowedIdentifier(context, node.typeName)) return true;
|
|
514
|
+
if (typeName?.includes(".")) {
|
|
515
|
+
const [namespaceName, memberName] = typeName.split(".");
|
|
516
|
+
return memberName === "Subscription" && rxjsNamespaces.has(namespaceName);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const [key, value] of Object.entries(node)) {
|
|
520
|
+
if (key === "parent") continue;
|
|
521
|
+
if (!value || typeof value !== "object") continue;
|
|
522
|
+
if (Array.isArray(value)) {
|
|
523
|
+
if (value.some((child) => hasSubscriptionTypeReference(context, child, subscriptionLocalNames, rxjsNamespaces))) return true;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (hasSubscriptionTypeReference(context, value, subscriptionLocalNames, rxjsNamespaces)) return true;
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
function isSubscriptionConstructor(context, node, subscriptionLocalNames, rxjsNamespaces) {
|
|
531
|
+
const expression = unwrapExpression(node);
|
|
532
|
+
if (expression?.type !== "NewExpression") return false;
|
|
533
|
+
const callee = unwrapExpression(expression.callee);
|
|
534
|
+
if (callee?.type === "Identifier") return subscriptionLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
535
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
536
|
+
return callee.object?.type === "Identifier" && rxjsNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && getPropertyName(callee.property) === "Subscription";
|
|
537
|
+
}
|
|
538
|
+
function getReferenceName(node) {
|
|
539
|
+
const expression = unwrapExpression(node);
|
|
540
|
+
if (!expression) return null;
|
|
541
|
+
if (expression.type === "Identifier") return expression.name;
|
|
542
|
+
if (expression.type !== "MemberExpression") return null;
|
|
543
|
+
if (expression.object?.type !== "ThisExpression") return null;
|
|
544
|
+
return getPropertyName(expression.property);
|
|
545
|
+
}
|
|
546
|
+
function isObservableReferenceName(name) {
|
|
547
|
+
return name.length > 1 && name.endsWith("$");
|
|
548
|
+
}
|
|
549
|
+
function addTrackedReference(references, name, binding) {
|
|
550
|
+
const bindings = references.get(name) ?? /* @__PURE__ */ new Set();
|
|
551
|
+
if (binding) bindings.add(binding);
|
|
552
|
+
references.set(name, bindings);
|
|
553
|
+
}
|
|
554
|
+
function hasTrackedReferenceName(references, name) {
|
|
555
|
+
return references.has(name);
|
|
556
|
+
}
|
|
557
|
+
function isTrackedReferenceExpression(context, node, references) {
|
|
558
|
+
const expression = unwrapExpression(node);
|
|
559
|
+
if (!expression) return false;
|
|
560
|
+
const referenceName = getReferenceName(expression);
|
|
561
|
+
if (!referenceName) return false;
|
|
562
|
+
const bindings = references.get(referenceName);
|
|
563
|
+
if (!bindings) return false;
|
|
564
|
+
if (expression.type === "MemberExpression" && expression.object?.type === "ThisExpression") return true;
|
|
565
|
+
if (expression.type !== "Identifier") return false;
|
|
566
|
+
const nearestBinding = findNearestBindingIdentifier(context, expression);
|
|
567
|
+
return !!nearestBinding && bindings.has(nearestBinding);
|
|
568
|
+
}
|
|
569
|
+
function getDeclaredName(node) {
|
|
570
|
+
if (!node) return null;
|
|
571
|
+
if (node.type === "Identifier") return node.name;
|
|
572
|
+
if (node.type === "PrivateIdentifier") return node.name;
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
function hasRxjsSubscribableTypeReference(context, node, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
576
|
+
if (!node) return false;
|
|
577
|
+
if (node.type === "TSTypeReference") {
|
|
578
|
+
const typeName = getTypeName(node.typeName);
|
|
579
|
+
if (node.typeName?.type === "Identifier" && typeName && rxjsSubscribableLocalNames.has(typeName) && !isShadowedIdentifier(context, node.typeName)) return true;
|
|
580
|
+
if (typeName?.includes(".")) {
|
|
581
|
+
const [namespaceName, memberName] = typeName.split(".");
|
|
582
|
+
return RXJS_SUBSCRIBABLE_NAMES.has(memberName) && rxjsNamespaces.has(namespaceName);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
for (const [key, value] of Object.entries(node)) {
|
|
586
|
+
if (key === "parent") continue;
|
|
587
|
+
if (!value || typeof value !== "object") continue;
|
|
588
|
+
if (Array.isArray(value)) {
|
|
589
|
+
if (value.some((child) => hasRxjsSubscribableTypeReference(context, child, rxjsSubscribableLocalNames, rxjsNamespaces))) return true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (hasRxjsSubscribableTypeReference(context, value, rxjsSubscribableLocalNames, rxjsNamespaces)) return true;
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
function isRxjsSubscribableConstructor(context, node, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
597
|
+
const expression = unwrapExpression(node);
|
|
598
|
+
if (expression?.type !== "NewExpression") return false;
|
|
599
|
+
const callee = unwrapExpression(expression.callee);
|
|
600
|
+
if (callee?.type === "Identifier") return rxjsSubscribableLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
601
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
602
|
+
return callee.object?.type === "Identifier" && rxjsNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && RXJS_SUBSCRIBABLE_NAMES.has(getPropertyName(callee.property) ?? "");
|
|
603
|
+
}
|
|
604
|
+
function isTakeUntilDestroyedCall(node, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
605
|
+
const callNode = unwrapExpression(node);
|
|
606
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
607
|
+
const callee = unwrapExpression(callNode.callee);
|
|
608
|
+
if (callee?.type === "Identifier") return takeUntilDestroyedLocalNames.has(callee.name);
|
|
609
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
610
|
+
return callee.object?.type === "Identifier" && interopNamespaces.has(callee.object.name) && getPropertyName(callee.property) === "takeUntilDestroyed";
|
|
611
|
+
}
|
|
612
|
+
function isTakeUntilDestroyedSubscribe(node, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
613
|
+
const callNode = unwrapExpression(node);
|
|
614
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
615
|
+
const callee = unwrapExpression(callNode.callee);
|
|
616
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
617
|
+
if (getPropertyName(callee.property) !== "subscribe") return false;
|
|
618
|
+
const source = unwrapExpression(callee.object);
|
|
619
|
+
if (source?.type !== "CallExpression") return false;
|
|
620
|
+
const sourceCallee = unwrapExpression(source.callee);
|
|
621
|
+
if (sourceCallee?.type !== "MemberExpression") return false;
|
|
622
|
+
if (getPropertyName(sourceCallee.property) !== "pipe") return false;
|
|
623
|
+
return (source.arguments ?? []).some((argument) => isTakeUntilDestroyedCall(argument, takeUntilDestroyedLocalNames, interopNamespaces));
|
|
624
|
+
}
|
|
625
|
+
function isKnownRxjsSubscribableExpression(context, node, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
626
|
+
const expression = unwrapExpression(node);
|
|
627
|
+
if (!expression) return false;
|
|
628
|
+
const referenceName = getReferenceName(expression);
|
|
629
|
+
if (referenceName && isObservableReferenceName(referenceName)) return true;
|
|
630
|
+
if (isTrackedReferenceExpression(context, expression, rxjsSubscribableReferences)) return true;
|
|
631
|
+
if (isRxjsSubscribableConstructor(context, expression, rxjsSubscribableLocalNames, rxjsNamespaces)) return true;
|
|
632
|
+
if (expression.type !== "CallExpression") return false;
|
|
633
|
+
const callee = unwrapExpression(expression.callee);
|
|
634
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
635
|
+
const methodName = getPropertyName(callee.property);
|
|
636
|
+
if (methodName !== "asObservable" && methodName !== "pipe") return false;
|
|
637
|
+
return isKnownRxjsSubscribableExpression(context, callee.object, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces);
|
|
638
|
+
}
|
|
639
|
+
function isUnmanagedSubscribeCall(context, node, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
640
|
+
const callNode = unwrapExpression(node);
|
|
641
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
642
|
+
const callee = unwrapExpression(callNode.callee);
|
|
643
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
644
|
+
if (getPropertyName(callee.property) !== "subscribe") return false;
|
|
645
|
+
return isKnownRxjsSubscribableExpression(context, callee.object, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces) && !isTakeUntilDestroyedSubscribe(node, takeUntilDestroyedLocalNames, interopNamespaces);
|
|
646
|
+
}
|
|
647
|
+
function walkNode$1(node, containingClass, visitor) {
|
|
648
|
+
if (!node) return;
|
|
649
|
+
if (Array.isArray(node)) {
|
|
650
|
+
for (const child of node) walkNode$1(child, containingClass, visitor);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (node !== containingClass && (node.type === "ClassDeclaration" || node.type === "ClassExpression")) return;
|
|
654
|
+
if (visitor(node) === false) return;
|
|
655
|
+
for (const [key, value] of Object.entries(node)) {
|
|
656
|
+
if (key === "parent") continue;
|
|
657
|
+
if (!value || typeof value !== "object") continue;
|
|
658
|
+
walkNode$1(value, containingClass, visitor);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function containsUnmanagedSubscribeCall(context, node, containingClass, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
662
|
+
let found = false;
|
|
663
|
+
walkNode$1(node, containingClass, (current) => {
|
|
664
|
+
if (isUnmanagedSubscribeCall(context, current, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
|
|
665
|
+
found = true;
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
return found;
|
|
670
|
+
}
|
|
671
|
+
function collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
672
|
+
const references = /* @__PURE__ */ new Map();
|
|
673
|
+
let changed = true;
|
|
674
|
+
while (changed) {
|
|
675
|
+
changed = false;
|
|
676
|
+
walkNode$1(classBody, classNode, (node) => {
|
|
677
|
+
if (FIELD_NODE_TYPES$2.has(node.type)) {
|
|
678
|
+
const name = getDeclaredName(node.key);
|
|
679
|
+
if (!name || hasTrackedReferenceName(references, name)) return;
|
|
680
|
+
if (isObservableReferenceName(name) || hasRxjsSubscribableTypeReference(context, node.typeAnnotation?.typeAnnotation, rxjsSubscribableLocalNames, rxjsNamespaces) || isRxjsSubscribableConstructor(context, node.value, rxjsSubscribableLocalNames, rxjsNamespaces) || isKnownRxjsSubscribableExpression(context, node.value, references, rxjsSubscribableLocalNames, rxjsNamespaces)) {
|
|
681
|
+
addTrackedReference(references, name, node.key);
|
|
682
|
+
changed = true;
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier") {
|
|
687
|
+
if (hasTrackedReferenceName(references, node.id.name)) return;
|
|
688
|
+
if (isObservableReferenceName(node.id.name) || hasRxjsSubscribableTypeReference(context, node.id.typeAnnotation?.typeAnnotation, rxjsSubscribableLocalNames, rxjsNamespaces) || isRxjsSubscribableConstructor(context, node.init, rxjsSubscribableLocalNames, rxjsNamespaces) || isKnownRxjsSubscribableExpression(context, node.init, references, rxjsSubscribableLocalNames, rxjsNamespaces)) {
|
|
689
|
+
addTrackedReference(references, node.id.name, node.id);
|
|
690
|
+
changed = true;
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (node.type === "AssignmentExpression") {
|
|
695
|
+
const name = getReferenceName(node.left);
|
|
696
|
+
if (!name || hasTrackedReferenceName(references, name)) return;
|
|
697
|
+
if (isObservableReferenceName(name) || isRxjsSubscribableConstructor(context, node.right, rxjsSubscribableLocalNames, rxjsNamespaces) || isKnownRxjsSubscribableExpression(context, node.right, references, rxjsSubscribableLocalNames, rxjsNamespaces)) {
|
|
698
|
+
addTrackedReference(references, name, findNearestBindingIdentifier(context, node.left));
|
|
699
|
+
changed = true;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return references;
|
|
705
|
+
}
|
|
706
|
+
function collectSubscriptionReferences(context, classBody, classNode, subscriptionLocalNames, rxjsNamespaces, rxjsSubscribableReferences, rxjsSubscribableLocalNames, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
707
|
+
const references = /* @__PURE__ */ new Map();
|
|
708
|
+
walkNode$1(classBody, classNode, (node) => {
|
|
709
|
+
if (FIELD_NODE_TYPES$2.has(node.type)) {
|
|
710
|
+
const name = getDeclaredName(node.key);
|
|
711
|
+
if (!name) return;
|
|
712
|
+
if (hasSubscriptionTypeReference(context, node.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces) || isSubscriptionConstructor(context, node.value, subscriptionLocalNames, rxjsNamespaces)) addTrackedReference(references, name, node.key);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier") {
|
|
716
|
+
if (hasSubscriptionTypeReference(context, node.id.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces) || isSubscriptionConstructor(context, node.init, subscriptionLocalNames, rxjsNamespaces) || isUnmanagedSubscribeCall(context, node.init, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) addTrackedReference(references, node.id.name, node.id);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (node.type === "AssignmentExpression") {
|
|
720
|
+
const name = getReferenceName(node.left);
|
|
721
|
+
if (!name) return;
|
|
722
|
+
if (isSubscriptionConstructor(context, node.right, subscriptionLocalNames, rxjsNamespaces) || isUnmanagedSubscribeCall(context, node.right, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) addTrackedReference(references, name, findNearestBindingIdentifier(context, node.left));
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
return references;
|
|
726
|
+
}
|
|
727
|
+
function isSubscriptionAddCall(context, node, containingClass, subscriptionReferences, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
728
|
+
const callNode = unwrapExpression(node);
|
|
729
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
730
|
+
const callee = unwrapExpression(callNode.callee);
|
|
731
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
732
|
+
if (getPropertyName(callee.property) !== "add") return false;
|
|
733
|
+
if (isTrackedReferenceExpression(context, callee.object, subscriptionReferences)) return true;
|
|
734
|
+
return (callNode.arguments ?? []).some((argument) => isTrackedReferenceExpression(context, argument, subscriptionReferences) || containsUnmanagedSubscribeCall(context, argument, containingClass, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces));
|
|
735
|
+
}
|
|
736
|
+
function isSubscriptionUnsubscribeCall(context, node, subscriptionReferences) {
|
|
737
|
+
const callNode = unwrapExpression(node);
|
|
738
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
739
|
+
const callee = unwrapExpression(callNode.callee);
|
|
740
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
741
|
+
if (getPropertyName(callee.property) !== "unsubscribe") return false;
|
|
742
|
+
return isTrackedReferenceExpression(context, callee.object, subscriptionReferences);
|
|
743
|
+
}
|
|
744
|
+
const avoidExplicitSubscriptionManagement = defineRule({
|
|
745
|
+
meta: {
|
|
746
|
+
type: "suggestion",
|
|
747
|
+
docs: {
|
|
748
|
+
description: "Avoid manual RxJS Subscription lifecycle management in Angular components, directives, and services.",
|
|
749
|
+
recommended: true
|
|
750
|
+
},
|
|
751
|
+
schema: [],
|
|
752
|
+
messages: {
|
|
753
|
+
explicitSubscriptionType: "Avoid storing RxJS Subscription references in Angular classes. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
754
|
+
explicitSubscriptionConstructor: "Avoid creating RxJS Subscription instances in Angular classes. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
755
|
+
explicitSubscribe: "Avoid subscribe() calls that require manual lifecycle management. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
756
|
+
explicitSubscriptionAdd: "Avoid adding subscriptions to a Subscription container. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
757
|
+
explicitUnsubscribe: "Avoid manual unsubscribe() calls in Angular classes. Prefer takeUntilDestroyed, toSignal, or firstValueFrom."
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
createOnce(context) {
|
|
761
|
+
const subscriptionLocalNames = /* @__PURE__ */ new Set();
|
|
762
|
+
const rxjsSubscribableLocalNames = /* @__PURE__ */ new Set();
|
|
763
|
+
const rxjsNamespaces = /* @__PURE__ */ new Set();
|
|
764
|
+
const takeUntilDestroyedLocalNames = /* @__PURE__ */ new Set();
|
|
765
|
+
const interopNamespaces = /* @__PURE__ */ new Set();
|
|
170
766
|
return {
|
|
171
767
|
before() {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
768
|
+
subscriptionLocalNames.clear();
|
|
769
|
+
rxjsSubscribableLocalNames.clear();
|
|
770
|
+
rxjsNamespaces.clear();
|
|
771
|
+
takeUntilDestroyedLocalNames.clear();
|
|
772
|
+
interopNamespaces.clear();
|
|
177
773
|
},
|
|
178
774
|
ImportDeclaration(node) {
|
|
179
|
-
|
|
180
|
-
for (const specifier of node.specifiers ?? []) {
|
|
775
|
+
const source = node.source?.value;
|
|
776
|
+
if (source === "rxjs") for (const specifier of node.specifiers ?? []) {
|
|
181
777
|
if (specifier.type === "ImportSpecifier") {
|
|
182
778
|
const importedName = getPropertyName(specifier.imported);
|
|
183
|
-
if (importedName === "
|
|
184
|
-
if (importedName
|
|
185
|
-
|
|
186
|
-
|
|
779
|
+
if (importedName === "Subscription") subscriptionLocalNames.add(specifier.local.name);
|
|
780
|
+
if (RXJS_SUBSCRIBABLE_NAMES.has(importedName ?? "")) rxjsSubscribableLocalNames.add(specifier.local.name);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (specifier.type === "ImportNamespaceSpecifier") rxjsNamespaces.add(specifier.local.name);
|
|
784
|
+
}
|
|
785
|
+
if (source === "@angular/core/rxjs-interop") for (const specifier of node.specifiers ?? []) {
|
|
786
|
+
if (specifier.type === "ImportSpecifier") {
|
|
787
|
+
if (getPropertyName(specifier.imported) === "takeUntilDestroyed") takeUntilDestroyedLocalNames.add(specifier.local.name);
|
|
788
|
+
continue;
|
|
187
789
|
}
|
|
188
|
-
if (specifier.type === "ImportNamespaceSpecifier")
|
|
790
|
+
if (specifier.type === "ImportNamespaceSpecifier") interopNamespaces.add(specifier.local.name);
|
|
189
791
|
}
|
|
190
792
|
},
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
793
|
+
ClassBody(node) {
|
|
794
|
+
const classBody = node;
|
|
795
|
+
const classNode = classBody.parent;
|
|
796
|
+
if (!classNode || !hasTargetDecorator$3(context, classNode)) return;
|
|
797
|
+
const rxjsSubscribableReferences = collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSubscribableLocalNames, rxjsNamespaces);
|
|
798
|
+
const subscriptionReferences = collectSubscriptionReferences(context, classBody, classNode, subscriptionLocalNames, rxjsNamespaces, rxjsSubscribableReferences, rxjsSubscribableLocalNames, takeUntilDestroyedLocalNames, interopNamespaces);
|
|
799
|
+
walkNode$1(classBody, classNode, (current) => {
|
|
800
|
+
if (FIELD_NODE_TYPES$2.has(current.type)) {
|
|
801
|
+
if (hasSubscriptionTypeReference(context, current.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces)) context.report({
|
|
802
|
+
node: current.key ?? current,
|
|
803
|
+
messageId: "explicitSubscriptionType"
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (current.type === "VariableDeclarator" && current.id?.type === "Identifier" && hasSubscriptionTypeReference(context, current.id.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces)) {
|
|
808
|
+
context.report({
|
|
809
|
+
node: current.id,
|
|
810
|
+
messageId: "explicitSubscriptionType"
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (isSubscriptionConstructor(context, current, subscriptionLocalNames, rxjsNamespaces)) {
|
|
815
|
+
context.report({
|
|
816
|
+
node: current,
|
|
817
|
+
messageId: "explicitSubscriptionConstructor"
|
|
818
|
+
});
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
if (isSubscriptionAddCall(context, current, classNode, subscriptionReferences, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
|
|
822
|
+
context.report({
|
|
823
|
+
node: current,
|
|
824
|
+
messageId: "explicitSubscriptionAdd"
|
|
825
|
+
});
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
if (isSubscriptionUnsubscribeCall(context, current, subscriptionReferences)) {
|
|
829
|
+
context.report({
|
|
830
|
+
node: current,
|
|
831
|
+
messageId: "explicitUnsubscribe"
|
|
832
|
+
});
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
if (isUnmanagedSubscribeCall(context, current, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
|
|
836
|
+
context.report({
|
|
837
|
+
node: current,
|
|
838
|
+
messageId: "explicitSubscribe"
|
|
839
|
+
});
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
203
842
|
});
|
|
204
843
|
}
|
|
205
844
|
};
|
|
@@ -210,9 +849,15 @@ const avoidExplicitInjectionContext = defineRule({
|
|
|
210
849
|
const DEFAULT_ALLOW_FOR_GROUPING = true;
|
|
211
850
|
const DEFAULT_ALLOW_FOR_PROVIDING = false;
|
|
212
851
|
const DEFAULT_ALLOW_FOR_ROUTING = false;
|
|
213
|
-
function
|
|
214
|
-
if (node.type !== "Decorator") return
|
|
215
|
-
|
|
852
|
+
function isNgModuleDecorator(context, node) {
|
|
853
|
+
if (node.type !== "Decorator") return false;
|
|
854
|
+
const expression = node.expression ?? node;
|
|
855
|
+
const callee = expression?.type === "CallExpression" ? expression.callee : expression;
|
|
856
|
+
if (callee?.type === "Identifier") return getImportedName(context, callee, "@angular/core") === "NgModule";
|
|
857
|
+
return callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && isNamespaceImport(context, callee.object, "@angular/core") && getPropertyName(callee.property) === "NgModule";
|
|
858
|
+
}
|
|
859
|
+
function getNgModuleMetadata(context, node) {
|
|
860
|
+
if (!isNgModuleDecorator(context, node)) return null;
|
|
216
861
|
const expression = node.expression;
|
|
217
862
|
if (expression?.type !== "CallExpression") return null;
|
|
218
863
|
const metadata = expression.arguments?.[0];
|
|
@@ -226,8 +871,16 @@ function getArrayElementsFromProperty(metadata, propertyName) {
|
|
|
226
871
|
function isMemberCall(node, methodName) {
|
|
227
872
|
return node.type === "CallExpression" && getPropertyName(node.callee?.property) === methodName;
|
|
228
873
|
}
|
|
229
|
-
function
|
|
230
|
-
|
|
874
|
+
function isModuleCallFromImport(context, node, expectedSource, expectedModuleName) {
|
|
875
|
+
if (node.type !== "CallExpression") return false;
|
|
876
|
+
const callee = node.callee;
|
|
877
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
878
|
+
const moduleRef = callee.object;
|
|
879
|
+
if (moduleRef?.type === "Identifier") return getImportedName(context, moduleRef, expectedSource) === expectedModuleName;
|
|
880
|
+
if (moduleRef?.type !== "MemberExpression") return false;
|
|
881
|
+
if (moduleRef.object?.type !== "Identifier") return false;
|
|
882
|
+
if (!isNamespaceImport(context, moduleRef.object, expectedSource)) return false;
|
|
883
|
+
return getPropertyName(moduleRef.property) === expectedModuleName;
|
|
231
884
|
}
|
|
232
885
|
function getNgModuleClassNode(decoratorNode) {
|
|
233
886
|
const parent = decoratorNode.parent;
|
|
@@ -264,45 +917,279 @@ const avoidNgModules = defineRule({
|
|
|
264
917
|
}
|
|
265
918
|
}],
|
|
266
919
|
messages: {
|
|
267
|
-
avoidModuleImportsExports: "NgModule imports/exports are legacy composition. Prefer using standalone things directly.",
|
|
268
|
-
avoidForRoot: "Avoid module.forRoot(...). Prefer provideX(...) functions to register injection providers.",
|
|
269
|
-
|
|
920
|
+
avoidModuleImportsExports: "NgModule imports/exports are legacy composition. Prefer using standalone things directly.",
|
|
921
|
+
avoidForRoot: "Avoid module.forRoot(...). Prefer provideX(...) functions to register injection providers.",
|
|
922
|
+
avoidRouterForRoot: "Avoid RouterModule.forRoot(routes). Prefer provideRouter(routes). See https://angular.dev/guide/routing/define-routes",
|
|
923
|
+
avoidRouterForChild: "Avoid RouterModule.forChild(routes). Prefer provideRouter(...) and lazy route entries such as loadComponent: () => import('./components/auth/login-page').",
|
|
924
|
+
avoidNgrxStoreForRoot: "Avoid StoreModule.forRoot(...). Prefer provideStore(...) with standalone providers. See https://ngrx.io/guide/store",
|
|
925
|
+
avoidNgrxEffectsForRoot: "Avoid EffectsModule.forRoot(...). Prefer provideEffects(...) with standalone providers. See https://ngrx.io/guide/effects",
|
|
926
|
+
avoidNgrxStoreForFeature: "Avoid StoreModule.forFeature(...). Prefer provideState(...) with standalone providers. See https://ngrx.io/guide/store",
|
|
927
|
+
avoidNgrxEffectsForFeature: "Avoid EffectsModule.forFeature(...). Prefer provideEffects(...) scoped to feature providers. See https://ngrx.io/guide/effects",
|
|
928
|
+
avoidNgrxStoreDevtoolsInstrument: "Avoid StoreDevtoolsModule.instrument(...). Prefer provideStoreDevtools(...) with standalone providers. See https://ngrx.io/guide/store-devtools",
|
|
929
|
+
avoidNgrxRouterStoreForRoot: "Avoid StoreRouterConnectingModule.forRoot(...). Prefer provideRouterStore(...) with standalone providers. See https://ngrx.io/guide/router-store"
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
createOnce(context) {
|
|
933
|
+
return { Decorator(node) {
|
|
934
|
+
const options = context.options[0] ?? {};
|
|
935
|
+
const allowForGrouping = options.allowForGrouping ?? DEFAULT_ALLOW_FOR_GROUPING;
|
|
936
|
+
const allowForProviding = options.allowForProviding ?? DEFAULT_ALLOW_FOR_PROVIDING;
|
|
937
|
+
const allowForRouting = options.allowForRouting ?? DEFAULT_ALLOW_FOR_ROUTING;
|
|
938
|
+
const metadata = getNgModuleMetadata(context, node);
|
|
939
|
+
if (!metadata) return;
|
|
940
|
+
const importsElements = getArrayElementsFromProperty(metadata, "imports");
|
|
941
|
+
const exportsElements = getArrayElementsFromProperty(metadata, "exports");
|
|
942
|
+
if (!allowForGrouping && (importsElements.length > 0 || exportsElements.length > 0)) context.report({
|
|
943
|
+
node,
|
|
944
|
+
messageId: "avoidModuleImportsExports"
|
|
945
|
+
});
|
|
946
|
+
const importCalls = importsElements.filter((element) => element.type === "CallExpression");
|
|
947
|
+
if (!allowForProviding) {
|
|
948
|
+
for (const call of importCalls) {
|
|
949
|
+
const methodName = getPropertyName(call.callee?.property);
|
|
950
|
+
if (methodName === "forRoot" && isModuleCallFromImport(context, call, "@angular/router", "RouterModule") && !allowForRouting) {
|
|
951
|
+
context.report({
|
|
952
|
+
node: call,
|
|
953
|
+
messageId: "avoidRouterForRoot"
|
|
954
|
+
});
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if (methodName === "forRoot" && isModuleCallFromImport(context, call, "@ngrx/store", "StoreModule")) {
|
|
958
|
+
context.report({
|
|
959
|
+
node: call,
|
|
960
|
+
messageId: "avoidNgrxStoreForRoot"
|
|
961
|
+
});
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (methodName === "forRoot" && isModuleCallFromImport(context, call, "@ngrx/effects", "EffectsModule")) {
|
|
965
|
+
context.report({
|
|
966
|
+
node: call,
|
|
967
|
+
messageId: "avoidNgrxEffectsForRoot"
|
|
968
|
+
});
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (methodName === "forFeature" && isModuleCallFromImport(context, call, "@ngrx/store", "StoreModule")) {
|
|
972
|
+
context.report({
|
|
973
|
+
node: call,
|
|
974
|
+
messageId: "avoidNgrxStoreForFeature"
|
|
975
|
+
});
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (methodName === "forFeature" && isModuleCallFromImport(context, call, "@ngrx/effects", "EffectsModule")) {
|
|
979
|
+
context.report({
|
|
980
|
+
node: call,
|
|
981
|
+
messageId: "avoidNgrxEffectsForFeature"
|
|
982
|
+
});
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
if (methodName === "instrument" && isModuleCallFromImport(context, call, "@ngrx/store-devtools", "StoreDevtoolsModule")) {
|
|
986
|
+
context.report({
|
|
987
|
+
node: call,
|
|
988
|
+
messageId: "avoidNgrxStoreDevtoolsInstrument"
|
|
989
|
+
});
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (methodName === "forRoot" && isModuleCallFromImport(context, call, "@ngrx/router-store", "StoreRouterConnectingModule")) {
|
|
993
|
+
context.report({
|
|
994
|
+
node: call,
|
|
995
|
+
messageId: "avoidNgrxRouterStoreForRoot"
|
|
996
|
+
});
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (methodName === "forRoot" && isModuleCallFromImport(context, call, "@angular/router", "RouterModule") && allowForRouting) continue;
|
|
1000
|
+
if (!isMemberCall(call, "forRoot")) continue;
|
|
1001
|
+
context.report({
|
|
1002
|
+
node: call,
|
|
1003
|
+
messageId: "avoidForRoot"
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
const staticForRootMethod = getStaticForRootMethod(getNgModuleClassNode(node));
|
|
1007
|
+
if (staticForRootMethod) context.report({
|
|
1008
|
+
node: staticForRootMethod.key ?? staticForRootMethod,
|
|
1009
|
+
messageId: "avoidForRoot"
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
if (!allowForRouting) for (const call of importCalls) {
|
|
1013
|
+
if (!isMemberCall(call, "forChild")) continue;
|
|
1014
|
+
if (!isModuleCallFromImport(context, call, "@angular/router", "RouterModule")) continue;
|
|
1015
|
+
context.report({
|
|
1016
|
+
node: call,
|
|
1017
|
+
messageId: "avoidRouterForChild"
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
} };
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/rules/avoid-rxjs-state-in-component/index.ts
|
|
1025
|
+
const TARGET_DECORATORS$1 = new Set(["Component", "Directive"]);
|
|
1026
|
+
const SUBJECT_NAMES = new Set([
|
|
1027
|
+
"BehaviorSubject",
|
|
1028
|
+
"ReplaySubject",
|
|
1029
|
+
"Subject"
|
|
1030
|
+
]);
|
|
1031
|
+
const FIELD_NODE_TYPES$1 = new Set([
|
|
1032
|
+
"AccessorProperty",
|
|
1033
|
+
"FieldDefinition",
|
|
1034
|
+
"PropertyDefinition"
|
|
1035
|
+
]);
|
|
1036
|
+
function hasTargetDecorator$2(context, classNode) {
|
|
1037
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
1038
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS$1));
|
|
1039
|
+
}
|
|
1040
|
+
function getImportedSubjectKind(context, node) {
|
|
1041
|
+
const importedName = getImportedName(context, node, "rxjs");
|
|
1042
|
+
return SUBJECT_NAMES.has(importedName) ? importedName : null;
|
|
1043
|
+
}
|
|
1044
|
+
function getSubjectKindFromType(context, node) {
|
|
1045
|
+
if (!node) return null;
|
|
1046
|
+
if (node.type !== "TSTypeReference") return null;
|
|
1047
|
+
if (node.typeName?.type === "Identifier") return getImportedSubjectKind(context, node.typeName);
|
|
1048
|
+
if (node.typeName?.type === "TSQualifiedName" && node.typeName.left?.type === "Identifier") {
|
|
1049
|
+
const memberName = getPropertyName(node.typeName.right);
|
|
1050
|
+
return isNamespaceImport(context, node.typeName.left, "rxjs") && SUBJECT_NAMES.has(memberName) ? memberName : null;
|
|
1051
|
+
}
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
function getSubjectKindFromConstructor(context, node) {
|
|
1055
|
+
const expression = unwrapExpression(node);
|
|
1056
|
+
if (expression?.type !== "NewExpression") return null;
|
|
1057
|
+
const callee = unwrapExpression(expression.callee);
|
|
1058
|
+
if (callee?.type === "Identifier") return getImportedSubjectKind(context, callee);
|
|
1059
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
1060
|
+
if (callee.object?.type !== "Identifier" || !isNamespaceImport(context, callee.object, "rxjs")) return null;
|
|
1061
|
+
const memberName = getPropertyName(callee.property);
|
|
1062
|
+
return SUBJECT_NAMES.has(memberName) ? memberName : null;
|
|
1063
|
+
}
|
|
1064
|
+
function getMemberName(node) {
|
|
1065
|
+
const expression = unwrapExpression(node);
|
|
1066
|
+
if (!expression) return null;
|
|
1067
|
+
if (expression.type === "Identifier") return expression.name;
|
|
1068
|
+
if (expression.type === "PrivateIdentifier") return expression.name;
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
function getThisFieldName(node) {
|
|
1072
|
+
const expression = unwrapExpression(node);
|
|
1073
|
+
if (expression?.type !== "MemberExpression") return null;
|
|
1074
|
+
if (expression.object?.type !== "ThisExpression") return null;
|
|
1075
|
+
return getPropertyName(expression.property);
|
|
1076
|
+
}
|
|
1077
|
+
function isCallOnThisField(node, fields) {
|
|
1078
|
+
const callNode = unwrapExpression(node);
|
|
1079
|
+
if (callNode?.type !== "CallExpression") return null;
|
|
1080
|
+
const callee = unwrapExpression(callNode.callee);
|
|
1081
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
1082
|
+
const fieldName = getThisFieldName(callee.object);
|
|
1083
|
+
const methodName = getPropertyName(callee.property);
|
|
1084
|
+
if (!fieldName || !methodName || !fields.has(fieldName)) return null;
|
|
1085
|
+
return {
|
|
1086
|
+
fieldName,
|
|
1087
|
+
methodName,
|
|
1088
|
+
argumentCount: callNode.arguments?.length ?? 0
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function isNgOnDestroyMethod(member) {
|
|
1092
|
+
return member.type === "MethodDefinition" && getPropertyName(member.key) === "ngOnDestroy" && !!member.value;
|
|
1093
|
+
}
|
|
1094
|
+
function walkNode(node, visitor) {
|
|
1095
|
+
if (!node) return;
|
|
1096
|
+
if (Array.isArray(node)) {
|
|
1097
|
+
for (const child of node) walkNode(child, visitor);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
visitor(node);
|
|
1101
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1102
|
+
if (key === "parent") continue;
|
|
1103
|
+
if (!value || typeof value !== "object") continue;
|
|
1104
|
+
walkNode(value, visitor);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function getUsage(usages, fieldName) {
|
|
1108
|
+
const existing = usages.get(fieldName);
|
|
1109
|
+
if (existing) return existing;
|
|
1110
|
+
const next = {
|
|
1111
|
+
hasAsObservable: false,
|
|
1112
|
+
hasNextWithValue: false,
|
|
1113
|
+
hasNgOnDestroyComplete: false,
|
|
1114
|
+
hasNgOnDestroyNext: false
|
|
1115
|
+
};
|
|
1116
|
+
usages.set(fieldName, next);
|
|
1117
|
+
return next;
|
|
1118
|
+
}
|
|
1119
|
+
function collectSubjectFields(context, classBody) {
|
|
1120
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1121
|
+
for (const member of classBody.body ?? []) {
|
|
1122
|
+
if (!FIELD_NODE_TYPES$1.has(member.type)) continue;
|
|
1123
|
+
const fieldName = getMemberName(member.key);
|
|
1124
|
+
if (!fieldName) continue;
|
|
1125
|
+
const typeNode = member.typeAnnotation?.typeAnnotation;
|
|
1126
|
+
const kind = getSubjectKindFromConstructor(context, member.value) ?? getSubjectKindFromType(context, typeNode);
|
|
1127
|
+
if (!kind) continue;
|
|
1128
|
+
fields.set(fieldName, {
|
|
1129
|
+
key: member.key ?? member,
|
|
1130
|
+
kind
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
return fields;
|
|
1134
|
+
}
|
|
1135
|
+
function collectFieldUsages(classBody, fields) {
|
|
1136
|
+
const usages = /* @__PURE__ */ new Map();
|
|
1137
|
+
const ngOnDestroyMethods = /* @__PURE__ */ new Set();
|
|
1138
|
+
for (const member of classBody.body ?? []) if (isNgOnDestroyMethod(member)) ngOnDestroyMethods.add(member.value);
|
|
1139
|
+
walkNode(classBody, (node) => {
|
|
1140
|
+
const call = isCallOnThisField(node, fields);
|
|
1141
|
+
if (!call) return;
|
|
1142
|
+
const usage = getUsage(usages, call.fieldName);
|
|
1143
|
+
if (call.methodName === "asObservable") usage.hasAsObservable = true;
|
|
1144
|
+
if (call.methodName === "next" && call.argumentCount > 0) usage.hasNextWithValue = true;
|
|
1145
|
+
const isInsideNgOnDestroy = ngOnDestroyMethods.has(node.parent) || ngOnDestroyMethods.has(node);
|
|
1146
|
+
let current = node.parent;
|
|
1147
|
+
while (!isInsideNgOnDestroy && current) {
|
|
1148
|
+
if (ngOnDestroyMethods.has(current)) break;
|
|
1149
|
+
current = current.parent;
|
|
1150
|
+
}
|
|
1151
|
+
if (!current && !ngOnDestroyMethods.has(node.parent) && !ngOnDestroyMethods.has(node)) return;
|
|
1152
|
+
if (call.methodName === "next") usage.hasNgOnDestroyNext = true;
|
|
1153
|
+
if (call.methodName === "complete") usage.hasNgOnDestroyComplete = true;
|
|
1154
|
+
});
|
|
1155
|
+
return usages;
|
|
1156
|
+
}
|
|
1157
|
+
function isDestroySubjectUsage(usage) {
|
|
1158
|
+
return !!usage?.hasNgOnDestroyNext && !!usage.hasNgOnDestroyComplete;
|
|
1159
|
+
}
|
|
1160
|
+
const avoidRxjsStateInComponent = defineRule({
|
|
1161
|
+
meta: {
|
|
1162
|
+
type: "suggestion",
|
|
1163
|
+
docs: {
|
|
1164
|
+
description: "Avoid using RxJS Subject classes for component or directive state and destruction lifecycle.",
|
|
1165
|
+
recommended: true
|
|
1166
|
+
},
|
|
1167
|
+
schema: [],
|
|
1168
|
+
messages: {
|
|
1169
|
+
avoidRxjsState: "Avoid using RxJS {{kind}} for component/directive state. Prefer signal(), computed(), or linkedSignal().",
|
|
1170
|
+
avoidDestroySubject: "Avoid destroy Subject lifecycle management. Prefer takeUntilDestroyed() from @angular/core/rxjs-interop."
|
|
270
1171
|
}
|
|
271
1172
|
},
|
|
272
1173
|
createOnce(context) {
|
|
273
|
-
return {
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (!allowForGrouping && (importsElements.length > 0 || exportsElements.length > 0)) context.report({
|
|
283
|
-
node,
|
|
284
|
-
messageId: "avoidModuleImportsExports"
|
|
285
|
-
});
|
|
286
|
-
const importCalls = importsElements.filter((element) => element.type === "CallExpression");
|
|
287
|
-
if (!allowForProviding) {
|
|
288
|
-
for (const call of importCalls) {
|
|
289
|
-
if (!isMemberCall(call, "forRoot")) continue;
|
|
1174
|
+
return { ClassBody(node) {
|
|
1175
|
+
const classBody = node;
|
|
1176
|
+
const classNode = classBody.parent;
|
|
1177
|
+
if (!hasTargetDecorator$2(context, classNode)) return;
|
|
1178
|
+
const fields = collectSubjectFields(context, classBody);
|
|
1179
|
+
const usages = collectFieldUsages(classBody, fields);
|
|
1180
|
+
for (const [fieldName, field] of fields) {
|
|
1181
|
+
const usage = usages.get(fieldName);
|
|
1182
|
+
if (field.kind === "Subject" && isDestroySubjectUsage(usage)) {
|
|
290
1183
|
context.report({
|
|
291
|
-
node:
|
|
292
|
-
messageId: "
|
|
1184
|
+
node: field.key,
|
|
1185
|
+
messageId: "avoidDestroySubject"
|
|
293
1186
|
});
|
|
1187
|
+
continue;
|
|
294
1188
|
}
|
|
295
|
-
const staticForRootMethod = getStaticForRootMethod(getNgModuleClassNode(node));
|
|
296
|
-
if (staticForRootMethod) context.report({
|
|
297
|
-
node: staticForRootMethod.key ?? staticForRootMethod,
|
|
298
|
-
messageId: "avoidForRoot"
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
if (!allowForRouting) for (const call of importCalls) {
|
|
302
|
-
if (!isRouterModuleForChild(call)) continue;
|
|
303
1189
|
context.report({
|
|
304
|
-
node:
|
|
305
|
-
messageId: "
|
|
1190
|
+
node: field.key,
|
|
1191
|
+
messageId: "avoidRxjsState",
|
|
1192
|
+
data: { kind: field.kind }
|
|
306
1193
|
});
|
|
307
1194
|
}
|
|
308
1195
|
} };
|
|
@@ -322,39 +1209,49 @@ const KNOWN_SIGNAL_CREATION_FUNCTIONS = new Set([
|
|
|
322
1209
|
]);
|
|
323
1210
|
const LINKED_SIGNAL_CREATOR_NAME = "linkedSignal";
|
|
324
1211
|
const COMPUTED_CREATOR_NAME = "computed";
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
function isSignalCreatorCall(node, signalCreatorNames, angularNamespaces) {
|
|
1212
|
+
const EFFECT_CREATOR_NAMES = new Set(["effect"]);
|
|
1213
|
+
const COMPUTED_CREATOR_NAMES = new Set([COMPUTED_CREATOR_NAME]);
|
|
1214
|
+
const LINKED_SIGNAL_CREATOR_NAMES = new Set([LINKED_SIGNAL_CREATOR_NAME]);
|
|
1215
|
+
function isSignalCreatorCall(context, node) {
|
|
330
1216
|
if (node?.type !== "CallExpression") return false;
|
|
331
1217
|
const callee = node.callee;
|
|
332
|
-
return callee
|
|
1218
|
+
return isImportedReference(context, callee, "@angular/core", KNOWN_SIGNAL_CREATION_FUNCTIONS) || isImportedNamespaceMember(context, callee, "@angular/core", KNOWN_SIGNAL_CREATION_FUNCTIONS);
|
|
333
1219
|
}
|
|
334
|
-
function isEffectCall(
|
|
1220
|
+
function isEffectCall(context, node) {
|
|
335
1221
|
const callee = node.callee;
|
|
336
|
-
return callee
|
|
1222
|
+
return isImportedReference(context, callee, "@angular/core", EFFECT_CREATOR_NAMES) || isImportedNamespaceMember(context, callee, "@angular/core", EFFECT_CREATOR_NAMES);
|
|
337
1223
|
}
|
|
338
|
-
function isReactiveCreatorCall(node, creatorNames
|
|
1224
|
+
function isReactiveCreatorCall(context, node, creatorNames) {
|
|
339
1225
|
const callee = node.callee;
|
|
340
|
-
return callee
|
|
1226
|
+
return isImportedReference(context, callee, "@angular/core", creatorNames) || isImportedNamespaceMember(context, callee, "@angular/core", creatorNames);
|
|
341
1227
|
}
|
|
342
|
-
function isKnownSignalObject(objectNode,
|
|
1228
|
+
function isKnownSignalObject(context, objectNode, signalVariableBindings, classSignalProperties) {
|
|
343
1229
|
if (!objectNode) return false;
|
|
344
|
-
if (objectNode.type === "Identifier")
|
|
1230
|
+
if (objectNode.type === "Identifier") {
|
|
1231
|
+
const trackedBindings = signalVariableBindings.get(objectNode.name);
|
|
1232
|
+
if (!trackedBindings) return false;
|
|
1233
|
+
const nearestBinding = findNearestBindingIdentifier(context, objectNode);
|
|
1234
|
+
return !!nearestBinding && trackedBindings.has(nearestBinding);
|
|
1235
|
+
}
|
|
345
1236
|
return objectNode.type === "MemberExpression" && objectNode.object?.type === "ThisExpression" && objectNode.property?.type === "Identifier" && classSignalProperties.has(objectNode.property.name);
|
|
346
1237
|
}
|
|
347
|
-
|
|
1238
|
+
const FUNCTION_NODE_TYPES = new Set([
|
|
1239
|
+
"ArrowFunctionExpression",
|
|
1240
|
+
"FunctionExpression",
|
|
1241
|
+
"FunctionDeclaration"
|
|
1242
|
+
]);
|
|
1243
|
+
function visitNodes(node, visitor, skipFunctionBodies = false) {
|
|
348
1244
|
if (!node) return;
|
|
349
1245
|
if (Array.isArray(node)) {
|
|
350
|
-
for (const item of node) visitNodes
|
|
1246
|
+
for (const item of node) visitNodes(item, visitor, skipFunctionBodies);
|
|
351
1247
|
return;
|
|
352
1248
|
}
|
|
1249
|
+
if (skipFunctionBodies && FUNCTION_NODE_TYPES.has(node.type)) return;
|
|
353
1250
|
visitor(node);
|
|
354
1251
|
for (const [key, value] of Object.entries(node)) {
|
|
355
1252
|
if (key === "parent") continue;
|
|
356
1253
|
if (!value || typeof value !== "object") continue;
|
|
357
|
-
visitNodes
|
|
1254
|
+
visitNodes(value, visitor, skipFunctionBodies);
|
|
358
1255
|
}
|
|
359
1256
|
}
|
|
360
1257
|
const avoidWritingSignalsInReactiveContext = defineRule({
|
|
@@ -381,46 +1278,25 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
381
1278
|
messages: { avoidSignalWriteInReactiveContext: "Avoid setting signal values inside {{contextName}}; move writes outside reactive derivations and effects." }
|
|
382
1279
|
},
|
|
383
1280
|
createOnce(context) {
|
|
384
|
-
const
|
|
385
|
-
const computedNames = /* @__PURE__ */ new Set();
|
|
386
|
-
const linkedSignalNames = /* @__PURE__ */ new Set();
|
|
387
|
-
const signalCreatorNames = /* @__PURE__ */ new Set();
|
|
388
|
-
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
389
|
-
const signalVariables = /* @__PURE__ */ new Set();
|
|
1281
|
+
const signalVariableBindings = /* @__PURE__ */ new Map();
|
|
390
1282
|
const classSignalProperties = /* @__PURE__ */ new Set();
|
|
391
1283
|
return {
|
|
392
1284
|
before() {
|
|
393
|
-
|
|
394
|
-
computedNames.clear();
|
|
395
|
-
linkedSignalNames.clear();
|
|
396
|
-
signalCreatorNames.clear();
|
|
397
|
-
angularNamespaces.clear();
|
|
398
|
-
signalVariables.clear();
|
|
1285
|
+
signalVariableBindings.clear();
|
|
399
1286
|
classSignalProperties.clear();
|
|
400
1287
|
},
|
|
401
|
-
ImportDeclaration(node) {
|
|
402
|
-
if (node.source?.value !== "@angular/core") return;
|
|
403
|
-
for (const specifier of node.specifiers ?? []) {
|
|
404
|
-
if (specifier.type === "ImportSpecifier") {
|
|
405
|
-
const importedName = getPropertyName(specifier.imported);
|
|
406
|
-
if (importedName === EFFECT_CREATOR_NAME) effectNames.add(specifier.local.name);
|
|
407
|
-
if (importedName === COMPUTED_CREATOR_NAME) computedNames.add(specifier.local.name);
|
|
408
|
-
if (importedName === LINKED_SIGNAL_CREATOR_NAME) linkedSignalNames.add(specifier.local.name);
|
|
409
|
-
if (importedName && KNOWN_SIGNAL_CREATION_FUNCTIONS.has(importedName)) signalCreatorNames.add(specifier.local.name);
|
|
410
|
-
}
|
|
411
|
-
if (specifier.type === "ImportNamespaceSpecifier") angularNamespaces.add(specifier.local.name);
|
|
412
|
-
}
|
|
413
|
-
},
|
|
414
1288
|
VariableDeclarator(node) {
|
|
415
1289
|
const declarator = node;
|
|
416
1290
|
if (declarator.id?.type !== "Identifier") return;
|
|
417
|
-
if (!isSignalCreatorCall(declarator.init
|
|
418
|
-
|
|
1291
|
+
if (!isSignalCreatorCall(context, declarator.init)) return;
|
|
1292
|
+
const bindings = signalVariableBindings.get(declarator.id.name) ?? /* @__PURE__ */ new Set();
|
|
1293
|
+
bindings.add(declarator.id);
|
|
1294
|
+
signalVariableBindings.set(declarator.id.name, bindings);
|
|
419
1295
|
},
|
|
420
1296
|
"PropertyDefinition, FieldDefinition, AccessorProperty"(node) {
|
|
421
1297
|
const property = node;
|
|
422
1298
|
if (property.key?.type !== "Identifier") return;
|
|
423
|
-
if (!isSignalCreatorCall(property.value
|
|
1299
|
+
if (!isSignalCreatorCall(context, property.value)) return;
|
|
424
1300
|
classSignalProperties.add(property.key.name);
|
|
425
1301
|
},
|
|
426
1302
|
CallExpression(node) {
|
|
@@ -429,21 +1305,21 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
429
1305
|
const allowComputedAndLinkedSignals = options.allowComputedAndLinkedSignals ?? false;
|
|
430
1306
|
const callNode = node;
|
|
431
1307
|
const callbackCandidates = [];
|
|
432
|
-
if (!allowEffects && isEffectCall(
|
|
1308
|
+
if (!allowEffects && isEffectCall(context, callNode)) {
|
|
433
1309
|
const callback = callNode.arguments?.[0];
|
|
434
1310
|
if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") callbackCandidates.push({
|
|
435
1311
|
callback,
|
|
436
1312
|
contextName: "effect()"
|
|
437
1313
|
});
|
|
438
1314
|
}
|
|
439
|
-
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(
|
|
1315
|
+
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(context, callNode, COMPUTED_CREATOR_NAMES)) {
|
|
440
1316
|
const callback = callNode.arguments?.[0];
|
|
441
1317
|
if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") callbackCandidates.push({
|
|
442
1318
|
callback,
|
|
443
1319
|
contextName: "computed()"
|
|
444
1320
|
});
|
|
445
1321
|
}
|
|
446
|
-
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(
|
|
1322
|
+
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(context, callNode, LINKED_SIGNAL_CREATOR_NAMES)) for (const argumentNode of callNode.arguments ?? []) {
|
|
447
1323
|
const argument = argumentNode;
|
|
448
1324
|
if (argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression") {
|
|
449
1325
|
callbackCandidates.push({
|
|
@@ -464,77 +1340,167 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
464
1340
|
});
|
|
465
1341
|
}
|
|
466
1342
|
}
|
|
467
|
-
for (const { callback, contextName } of callbackCandidates) visitNodes
|
|
1343
|
+
for (const { callback, contextName } of callbackCandidates) visitNodes(callback.body, (current) => {
|
|
468
1344
|
if (current.type !== "CallExpression") return;
|
|
469
1345
|
const callee = current.callee;
|
|
470
1346
|
if (callee?.type !== "MemberExpression") return;
|
|
471
1347
|
const methodName = getPropertyName(callee.property);
|
|
472
1348
|
if (!methodName || !SIGNAL_WRITE_METHODS.has(methodName)) return;
|
|
473
|
-
if (!isKnownSignalObject(callee.object,
|
|
1349
|
+
if (!isKnownSignalObject(context, callee.object, signalVariableBindings, classSignalProperties)) return;
|
|
474
1350
|
context.report({
|
|
475
1351
|
node: callee.property ?? callee,
|
|
476
1352
|
messageId: "avoidSignalWriteInReactiveContext",
|
|
477
1353
|
data: { contextName }
|
|
478
1354
|
});
|
|
479
|
-
});
|
|
1355
|
+
}, true);
|
|
480
1356
|
},
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
computedNames.clear();
|
|
484
|
-
linkedSignalNames.clear();
|
|
485
|
-
signalCreatorNames.clear();
|
|
486
|
-
angularNamespaces.clear();
|
|
487
|
-
signalVariables.clear();
|
|
1357
|
+
after() {
|
|
1358
|
+
signalVariableBindings.clear();
|
|
488
1359
|
classSignalProperties.clear();
|
|
489
1360
|
}
|
|
490
1361
|
};
|
|
491
1362
|
}
|
|
492
1363
|
});
|
|
493
1364
|
//#endregion
|
|
494
|
-
//#region src/rules/
|
|
1365
|
+
//#region src/rules/class-matches-filename/index.ts
|
|
1366
|
+
const CLASS_FILENAME_MATCHERS = [
|
|
1367
|
+
{
|
|
1368
|
+
pattern: /^(.*)\.component\.ts$/u,
|
|
1369
|
+
decoratorNames: ["Component"]
|
|
1370
|
+
},
|
|
1371
|
+
{
|
|
1372
|
+
pattern: /^(.*)\.directive\.ts$/u,
|
|
1373
|
+
decoratorNames: ["Directive"]
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
pattern: /^(.*)\.service\.ts$/u,
|
|
1377
|
+
decoratorNames: ["Service", "Injectable"]
|
|
1378
|
+
}
|
|
1379
|
+
];
|
|
495
1380
|
function toPascalCase(raw) {
|
|
496
1381
|
return raw.split(/[-_\s.]+/u).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
497
1382
|
}
|
|
498
|
-
function
|
|
1383
|
+
function getClassKind(suffix) {
|
|
1384
|
+
return suffix.toLowerCase();
|
|
1385
|
+
}
|
|
1386
|
+
function getExpectedClassNames(filename, ignoreClassSuffix) {
|
|
499
1387
|
const base = filename.split(/[/\\]/u).at(-1) ?? "";
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1388
|
+
for (const { decoratorNames, pattern } of CLASS_FILENAME_MATCHERS) {
|
|
1389
|
+
const match = pattern.exec(base);
|
|
1390
|
+
if (!match) continue;
|
|
1391
|
+
const stem = match[1];
|
|
1392
|
+
const pascal = toPascalCase(stem);
|
|
1393
|
+
if (!pascal) return null;
|
|
1394
|
+
const suffix = decoratorNames[0];
|
|
1395
|
+
return ignoreClassSuffix ? [`${pascal}${suffix}`, pascal] : [`${pascal}${suffix}`];
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
function getFileMatcher(filename) {
|
|
1400
|
+
const base = filename.split(/[/\\]/u).at(-1) ?? "";
|
|
1401
|
+
return CLASS_FILENAME_MATCHERS.find(({ pattern }) => pattern.test(base)) ?? null;
|
|
505
1402
|
}
|
|
506
|
-
|
|
1403
|
+
function getIgnoreClassSuffixOption(options, kind) {
|
|
1404
|
+
const ignoreClassSuffix = options.ignoreClassSuffix ?? false;
|
|
1405
|
+
if (typeof ignoreClassSuffix === "boolean") return ignoreClassSuffix;
|
|
1406
|
+
return ignoreClassSuffix[kind] ?? false;
|
|
1407
|
+
}
|
|
1408
|
+
function getFileKind(matcher) {
|
|
1409
|
+
return getClassKind(matcher.decoratorNames[0]);
|
|
1410
|
+
}
|
|
1411
|
+
function formatExpectedNames(expectedNames) {
|
|
1412
|
+
return expectedNames.join("' or '");
|
|
1413
|
+
}
|
|
1414
|
+
function getNodeName(node) {
|
|
1415
|
+
return node.id?.name ?? null;
|
|
1416
|
+
}
|
|
1417
|
+
function getBaseFilename$1(filename) {
|
|
1418
|
+
return filename.split(/[/\\]/u).at(-1) ?? filename;
|
|
1419
|
+
}
|
|
1420
|
+
function hasTargetDecorator$1(context, classNode, decoratorNames) {
|
|
1421
|
+
if (!Array.isArray(classNode.decorators)) return false;
|
|
1422
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, decoratorNames));
|
|
1423
|
+
}
|
|
1424
|
+
function getDecoratedClassCountDescription(count) {
|
|
1425
|
+
return count === 0 ? "no decorated classes" : `${count} decorated classes`;
|
|
1426
|
+
}
|
|
1427
|
+
const classMatchesFilename = defineRule({
|
|
507
1428
|
meta: {
|
|
508
1429
|
type: "suggestion",
|
|
509
1430
|
docs: {
|
|
510
|
-
description: "Require
|
|
1431
|
+
description: "Require Angular component, directive, and service class names to match their filenames.",
|
|
511
1432
|
recommended: true
|
|
512
1433
|
},
|
|
513
|
-
schema: [
|
|
514
|
-
|
|
1434
|
+
schema: [{
|
|
1435
|
+
type: "object",
|
|
1436
|
+
additionalProperties: false,
|
|
1437
|
+
properties: { ignoreClassSuffix: {
|
|
1438
|
+
anyOf: [{ type: "boolean" }, {
|
|
1439
|
+
type: "object",
|
|
1440
|
+
additionalProperties: false,
|
|
1441
|
+
properties: {
|
|
1442
|
+
component: { type: "boolean" },
|
|
1443
|
+
directive: { type: "boolean" },
|
|
1444
|
+
service: { type: "boolean" }
|
|
1445
|
+
}
|
|
1446
|
+
}],
|
|
1447
|
+
default: false
|
|
1448
|
+
} }
|
|
1449
|
+
}],
|
|
1450
|
+
messages: {
|
|
1451
|
+
classNameMismatch: "Angular class name should be '{{expectedName}}' to match the filename '{{filename}}'.",
|
|
1452
|
+
decoratedClassCount: "Angular filename '{{filename}}' should contain exactly one matching decorated class, but found {{actual}}."
|
|
1453
|
+
}
|
|
515
1454
|
},
|
|
516
1455
|
createOnce(context) {
|
|
517
|
-
|
|
1456
|
+
let programNode = null;
|
|
1457
|
+
let fileMatcher = null;
|
|
1458
|
+
let fileDecoratorNames = /* @__PURE__ */ new Set();
|
|
1459
|
+
const decoratedClasses = [];
|
|
518
1460
|
return {
|
|
519
1461
|
before() {
|
|
520
|
-
|
|
1462
|
+
programNode = null;
|
|
1463
|
+
decoratedClasses.length = 0;
|
|
1464
|
+
fileMatcher = getFileMatcher(context.filename ?? "");
|
|
1465
|
+
if (!fileMatcher) return false;
|
|
1466
|
+
fileDecoratorNames = new Set(fileMatcher.decoratorNames);
|
|
1467
|
+
},
|
|
1468
|
+
Program(node) {
|
|
1469
|
+
programNode = node;
|
|
521
1470
|
},
|
|
522
1471
|
ClassDeclaration(node) {
|
|
523
|
-
|
|
1472
|
+
if (!fileMatcher) return;
|
|
1473
|
+
const classNode = node;
|
|
1474
|
+
if (hasTargetDecorator$1(context, classNode, fileDecoratorNames)) decoratedClasses.push(classNode);
|
|
524
1475
|
},
|
|
525
1476
|
after() {
|
|
526
1477
|
const filename = context.filename ?? "";
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1478
|
+
if (!fileMatcher) return;
|
|
1479
|
+
const baseFilename = getBaseFilename$1(filename);
|
|
1480
|
+
if (decoratedClasses.length !== 1) {
|
|
1481
|
+
const reportNode = decoratedClasses[0]?.id ?? decoratedClasses[0] ?? programNode;
|
|
1482
|
+
if (!reportNode) return;
|
|
1483
|
+
context.report({
|
|
1484
|
+
node: reportNode,
|
|
1485
|
+
messageId: "decoratedClassCount",
|
|
1486
|
+
data: {
|
|
1487
|
+
actual: getDecoratedClassCountDescription(decoratedClasses.length),
|
|
1488
|
+
filename: baseFilename
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const expectedNames = getExpectedClassNames(filename, getIgnoreClassSuffixOption(context.options[0] ?? {}, getFileKind(fileMatcher)));
|
|
1494
|
+
if (!expectedNames) return;
|
|
1495
|
+
const classNode = decoratedClasses[0];
|
|
1496
|
+
const className = getNodeName(classNode);
|
|
1497
|
+
if (!className) return;
|
|
1498
|
+
if (expectedNames.includes(className)) return;
|
|
533
1499
|
context.report({
|
|
534
1500
|
node: classNode.id,
|
|
535
1501
|
messageId: "classNameMismatch",
|
|
536
1502
|
data: {
|
|
537
|
-
expectedName,
|
|
1503
|
+
expectedName: formatExpectedNames(expectedNames),
|
|
538
1504
|
filename: baseFilename
|
|
539
1505
|
}
|
|
540
1506
|
});
|
|
@@ -543,13 +1509,208 @@ const componentClassMatchesFilename = defineRule({
|
|
|
543
1509
|
}
|
|
544
1510
|
});
|
|
545
1511
|
//#endregion
|
|
1512
|
+
//#region src/rules/component-resource-filenames/index.ts
|
|
1513
|
+
const COMPONENT_DECORATORS$1 = new Set(["Component"]);
|
|
1514
|
+
const STYLE_EXTENSIONS = new Set([
|
|
1515
|
+
"css",
|
|
1516
|
+
"less",
|
|
1517
|
+
"sass",
|
|
1518
|
+
"scss"
|
|
1519
|
+
]);
|
|
1520
|
+
function getExpectedStem(filename) {
|
|
1521
|
+
const base = filename.split(/[/\\]/u).at(-1) ?? "";
|
|
1522
|
+
if (!base.endsWith(".ts")) return null;
|
|
1523
|
+
return base.slice(0, -3);
|
|
1524
|
+
}
|
|
1525
|
+
function normalizeResourcePath(value) {
|
|
1526
|
+
return value.startsWith("./") ? value.slice(2) : value;
|
|
1527
|
+
}
|
|
1528
|
+
function getResourceBasename(value) {
|
|
1529
|
+
return normalizeResourcePath(value).split(/[/\\]/u).at(-1) ?? value;
|
|
1530
|
+
}
|
|
1531
|
+
function getExtension(value) {
|
|
1532
|
+
const basename = getResourceBasename(value);
|
|
1533
|
+
const index = basename.lastIndexOf(".");
|
|
1534
|
+
return index === -1 ? null : basename.slice(index + 1);
|
|
1535
|
+
}
|
|
1536
|
+
function getStaticString(node) {
|
|
1537
|
+
if (!node) return null;
|
|
1538
|
+
if (node.type === "Literal" || node.type === "StringLiteral") {
|
|
1539
|
+
if (typeof node.value !== "string") return null;
|
|
1540
|
+
return { value: node.value };
|
|
1541
|
+
}
|
|
1542
|
+
if (node.type === "TemplateLiteral" && node.expressions?.length === 0) return { value: node.quasis?.[0]?.value?.cooked ?? node.quasis?.[0]?.value?.raw ?? "" };
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
function getComponentMetadata(context, node) {
|
|
1546
|
+
if (node.type !== "Decorator") return null;
|
|
1547
|
+
if (!isAngularCoreDecorator(context, node, COMPONENT_DECORATORS$1)) return null;
|
|
1548
|
+
const expression = node.expression;
|
|
1549
|
+
if (expression?.type !== "CallExpression") return null;
|
|
1550
|
+
const metadata = expression.arguments?.[0];
|
|
1551
|
+
return metadata?.type === "ObjectExpression" ? metadata : null;
|
|
1552
|
+
}
|
|
1553
|
+
const componentResourceFilenames = defineRule({
|
|
1554
|
+
meta: {
|
|
1555
|
+
type: "suggestion",
|
|
1556
|
+
docs: {
|
|
1557
|
+
description: "Require Angular component templateUrl and styleUrl resource filenames to match the component TypeScript filename.",
|
|
1558
|
+
recommended: true
|
|
1559
|
+
},
|
|
1560
|
+
schema: [],
|
|
1561
|
+
messages: {
|
|
1562
|
+
templateUrlMismatch: "Component templateUrl should be '{{expected}}' to match this component filename.",
|
|
1563
|
+
styleUrlMismatch: "Component style URL should be '{{expected}}' to match this component filename.",
|
|
1564
|
+
unsupportedStyleExtension: "Component style URL should use one of: .css, .less, .sass, or .scss."
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
createOnce(context) {
|
|
1568
|
+
let expectedStem = null;
|
|
1569
|
+
function reportTemplateUrl(valueNode) {
|
|
1570
|
+
if (!expectedStem) return;
|
|
1571
|
+
const staticString = getStaticString(valueNode);
|
|
1572
|
+
if (!staticString) return;
|
|
1573
|
+
const expected = `./${expectedStem}.html`;
|
|
1574
|
+
if (normalizeResourcePath(staticString.value) === `${expectedStem}.html`) return;
|
|
1575
|
+
context.report({
|
|
1576
|
+
node: valueNode,
|
|
1577
|
+
messageId: "templateUrlMismatch",
|
|
1578
|
+
data: { expected }
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
function reportStyleUrl(valueNode) {
|
|
1582
|
+
if (!expectedStem) return;
|
|
1583
|
+
const staticString = getStaticString(valueNode);
|
|
1584
|
+
if (!staticString) return;
|
|
1585
|
+
const extension = getExtension(staticString.value);
|
|
1586
|
+
if (!extension || !STYLE_EXTENSIONS.has(extension)) {
|
|
1587
|
+
context.report({
|
|
1588
|
+
node: valueNode,
|
|
1589
|
+
messageId: "unsupportedStyleExtension"
|
|
1590
|
+
});
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const expected = `./${expectedStem}.${extension}`;
|
|
1594
|
+
if (normalizeResourcePath(staticString.value) === `${expectedStem}.${extension}`) return;
|
|
1595
|
+
context.report({
|
|
1596
|
+
node: valueNode,
|
|
1597
|
+
messageId: "styleUrlMismatch",
|
|
1598
|
+
data: { expected }
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
return {
|
|
1602
|
+
before() {
|
|
1603
|
+
expectedStem = getExpectedStem(context.filename ?? "");
|
|
1604
|
+
if (!expectedStem) return false;
|
|
1605
|
+
},
|
|
1606
|
+
Decorator(node) {
|
|
1607
|
+
const metadata = getComponentMetadata(context, node);
|
|
1608
|
+
if (!metadata) return;
|
|
1609
|
+
for (const property of metadata.properties ?? []) {
|
|
1610
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
1611
|
+
const propertyName = getPropertyName(property.key);
|
|
1612
|
+
if (propertyName === "templateUrl") {
|
|
1613
|
+
reportTemplateUrl(property.value);
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
if (propertyName === "styleUrl") {
|
|
1617
|
+
reportStyleUrl(property.value);
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
//#endregion
|
|
1626
|
+
//#region src/rules/decorator-filename-suffix/index.ts
|
|
1627
|
+
const FILENAME_SUFFIX_CONVENTIONS = [
|
|
1628
|
+
{
|
|
1629
|
+
kind: "component",
|
|
1630
|
+
suffix: ".component.ts",
|
|
1631
|
+
decoratorNames: new Set(["Component"])
|
|
1632
|
+
},
|
|
1633
|
+
{
|
|
1634
|
+
kind: "directive",
|
|
1635
|
+
suffix: ".directive.ts",
|
|
1636
|
+
decoratorNames: new Set(["Directive"])
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
kind: "service",
|
|
1640
|
+
suffix: ".service.ts",
|
|
1641
|
+
decoratorNames: new Set(["Service", "Injectable"])
|
|
1642
|
+
}
|
|
1643
|
+
];
|
|
1644
|
+
function getBaseFilename(filename) {
|
|
1645
|
+
return filename.split(/[/\\]/u).at(-1) ?? filename;
|
|
1646
|
+
}
|
|
1647
|
+
function isTypeScriptFile(filename) {
|
|
1648
|
+
return getBaseFilename(filename).endsWith(".ts");
|
|
1649
|
+
}
|
|
1650
|
+
function getMatchingConvention(context, decorator) {
|
|
1651
|
+
return FILENAME_SUFFIX_CONVENTIONS.find((convention) => isAngularCoreDecorator(context, decorator, convention.decoratorNames)) ?? null;
|
|
1652
|
+
}
|
|
1653
|
+
const decoratorFilenameSuffix = defineRule({
|
|
1654
|
+
meta: {
|
|
1655
|
+
type: "suggestion",
|
|
1656
|
+
docs: {
|
|
1657
|
+
description: "Require Angular component, directive, and service decorators to be declared in files with matching suffixes.",
|
|
1658
|
+
recommended: true
|
|
1659
|
+
},
|
|
1660
|
+
schema: [],
|
|
1661
|
+
messages: { filenameSuffix: "Angular {{kind}} decorator should be declared in a file ending with '{{suffix}}'." }
|
|
1662
|
+
},
|
|
1663
|
+
createOnce(context) {
|
|
1664
|
+
return {
|
|
1665
|
+
before() {
|
|
1666
|
+
if (!isTypeScriptFile(context.filename ?? "")) return false;
|
|
1667
|
+
},
|
|
1668
|
+
Decorator(node) {
|
|
1669
|
+
const convention = getMatchingConvention(context, node);
|
|
1670
|
+
if (!convention) return;
|
|
1671
|
+
if (getBaseFilename(context.filename ?? "").endsWith(convention.suffix)) return;
|
|
1672
|
+
context.report({
|
|
1673
|
+
node,
|
|
1674
|
+
messageId: "filenameSuffix",
|
|
1675
|
+
data: {
|
|
1676
|
+
kind: convention.kind,
|
|
1677
|
+
suffix: convention.suffix
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
//#endregion
|
|
546
1685
|
//#region src/rules/prefer-load-component-over-load-children/index.ts
|
|
547
|
-
|
|
1686
|
+
const ROUTE_TYPE_NAMES = new Set(["Route"]);
|
|
1687
|
+
const ROUTES_TYPE_NAMES = new Set(["Routes"]);
|
|
1688
|
+
function isImportedTypeName(context, typeNode, importedNames) {
|
|
1689
|
+
if (typeNode?.type === "Identifier") {
|
|
1690
|
+
const importedName = getImportedName(context, typeNode, "@angular/router");
|
|
1691
|
+
return !!importedName && importedNames.has(importedName);
|
|
1692
|
+
}
|
|
1693
|
+
return typeNode?.type === "TSQualifiedName" && typeNode.left?.type === "Identifier" && isNamespaceImport(context, typeNode.left, "@angular/router") && importedNames.has(getPropertyName(typeNode.right) ?? "");
|
|
1694
|
+
}
|
|
1695
|
+
function getTypeParameterNodes(typeNode) {
|
|
1696
|
+
return typeNode.typeParameters?.params ?? typeNode.typeArguments?.params ?? [];
|
|
1697
|
+
}
|
|
1698
|
+
function isRouteType(context, typeNode) {
|
|
1699
|
+
return typeNode?.type === "TSTypeReference" && isImportedTypeName(context, typeNode.typeName, ROUTE_TYPE_NAMES);
|
|
1700
|
+
}
|
|
1701
|
+
function isRouteArrayType(context, typeNode) {
|
|
1702
|
+
if (!typeNode) return false;
|
|
1703
|
+
if (typeNode.type === "TSTypeReference" && isImportedTypeName(context, typeNode.typeName, ROUTES_TYPE_NAMES)) return true;
|
|
1704
|
+
if (typeNode.type === "TSArrayType") return isRouteType(context, typeNode.elementType);
|
|
1705
|
+
if (typeNode.type !== "TSTypeReference") return false;
|
|
1706
|
+
if (getPropertyName(typeNode.typeName) !== "Array" && getPropertyName(typeNode.typeName) !== "ReadonlyArray") return false;
|
|
1707
|
+
const [elementType] = getTypeParameterNodes(typeNode);
|
|
1708
|
+
return isRouteType(context, elementType);
|
|
1709
|
+
}
|
|
1710
|
+
function isRoutesTypeAnnotation(context, node) {
|
|
548
1711
|
const typeAnnotation = node?.typeAnnotation;
|
|
549
1712
|
if (!typeAnnotation || typeAnnotation.type !== "TSTypeAnnotation") return false;
|
|
550
|
-
|
|
551
|
-
if (!annotation || annotation.type !== "TSTypeReference") return false;
|
|
552
|
-
return getPropertyName(annotation.typeName) === "Routes";
|
|
1713
|
+
return isRouteArrayType(context, typeAnnotation.typeAnnotation);
|
|
553
1714
|
}
|
|
554
1715
|
function isExportedConstDeclarator(node) {
|
|
555
1716
|
if (node.type !== "VariableDeclarator") return false;
|
|
@@ -557,19 +1718,6 @@ function isExportedConstDeclarator(node) {
|
|
|
557
1718
|
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") return false;
|
|
558
1719
|
return declaration.parent?.type === "ExportNamedDeclaration";
|
|
559
1720
|
}
|
|
560
|
-
function visitNodes(node, visitor) {
|
|
561
|
-
if (!node) return;
|
|
562
|
-
if (Array.isArray(node)) {
|
|
563
|
-
for (const item of node) visitNodes(item, visitor);
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
visitor(node);
|
|
567
|
-
for (const [key, value] of Object.entries(node)) {
|
|
568
|
-
if (key === "parent") continue;
|
|
569
|
-
if (!value || typeof value !== "object") continue;
|
|
570
|
-
visitNodes(value, visitor);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
1721
|
const preferLoadComponentOverLoadChildren = defineRule({
|
|
574
1722
|
meta: {
|
|
575
1723
|
type: "problem",
|
|
@@ -581,20 +1729,30 @@ const preferLoadComponentOverLoadChildren = defineRule({
|
|
|
581
1729
|
messages: { avoidLoadChildren: "Avoid loadChildren in Routes arrays; prefer loadComponent for lazy-loading standalone route components." }
|
|
582
1730
|
},
|
|
583
1731
|
createOnce(context) {
|
|
1732
|
+
function reportLoadChildrenInRouteObject(routeObject) {
|
|
1733
|
+
for (const property of routeObject.properties ?? []) {
|
|
1734
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
1735
|
+
const propertyName = getPropertyName(property.key);
|
|
1736
|
+
if (propertyName === "loadChildren") {
|
|
1737
|
+
context.report({
|
|
1738
|
+
node: property.key ?? property,
|
|
1739
|
+
messageId: "avoidLoadChildren"
|
|
1740
|
+
});
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
if (propertyName === "children" && property.value?.type === "ArrayExpression") reportLoadChildrenInRouteArray(property.value);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
function reportLoadChildrenInRouteArray(routeArray) {
|
|
1747
|
+
for (const element of routeArray.elements ?? []) if (element?.type === "ObjectExpression") reportLoadChildrenInRouteObject(element);
|
|
1748
|
+
}
|
|
584
1749
|
return { VariableDeclarator(node) {
|
|
585
1750
|
const declarator = node;
|
|
586
1751
|
if (!isExportedConstDeclarator(declarator)) return;
|
|
587
1752
|
if (declarator.id?.type !== "Identifier") return;
|
|
588
|
-
if (!isRoutesTypeAnnotation(declarator.id)) return;
|
|
1753
|
+
if (!isRoutesTypeAnnotation(context, declarator.id)) return;
|
|
589
1754
|
if (declarator.init?.type !== "ArrayExpression") return;
|
|
590
|
-
|
|
591
|
-
if (current.type !== "Property" || current.computed) return;
|
|
592
|
-
if (getPropertyName(current.key) !== "loadChildren") return;
|
|
593
|
-
context.report({
|
|
594
|
-
node: current.key ?? current,
|
|
595
|
-
messageId: "avoidLoadChildren"
|
|
596
|
-
});
|
|
597
|
-
});
|
|
1755
|
+
reportLoadChildrenInRouteArray(declarator.init);
|
|
598
1756
|
} };
|
|
599
1757
|
}
|
|
600
1758
|
});
|
|
@@ -604,6 +1762,7 @@ function getPrivateTarget(element) {
|
|
|
604
1762
|
if (element.type !== "PropertyDefinition" && element.type !== "FieldDefinition" && element.type !== "AccessorProperty" && element.type !== "MethodDefinition") return null;
|
|
605
1763
|
if (element.accessibility !== "private") return null;
|
|
606
1764
|
if (element.computed) return null;
|
|
1765
|
+
if (Array.isArray(element.decorators) && element.decorators.length > 0) return null;
|
|
607
1766
|
if (element.type === "MethodDefinition" && element.kind === "constructor") return null;
|
|
608
1767
|
const name = getPropertyName(element.key);
|
|
609
1768
|
if (!name || element.key?.type !== "Identifier") return null;
|
|
@@ -757,10 +1916,10 @@ const preferPrivateElements = defineRule({
|
|
|
757
1916
|
});
|
|
758
1917
|
//#endregion
|
|
759
1918
|
//#region src/rules/prefer-style-url/index.ts
|
|
760
|
-
|
|
1919
|
+
const COMPONENT_DECORATORS = new Set(["Component"]);
|
|
1920
|
+
function isComponentDecoratorCall(context, node) {
|
|
761
1921
|
const callee = node.callee;
|
|
762
|
-
|
|
763
|
-
return callee?.type === "MemberExpression" && getPropertyName(callee.property) === "Component";
|
|
1922
|
+
return isImportedReference(context, callee, "@angular/core", COMPONENT_DECORATORS) || isImportedNamespaceMember(context, callee, "@angular/core", COMPONENT_DECORATORS);
|
|
764
1923
|
}
|
|
765
1924
|
function isSingleStyleFileNode(node) {
|
|
766
1925
|
if (!node) return false;
|
|
@@ -781,7 +1940,7 @@ const preferStyleUrl = defineRule({
|
|
|
781
1940
|
createOnce(context) {
|
|
782
1941
|
return { CallExpression(node) {
|
|
783
1942
|
const call = node;
|
|
784
|
-
if (!isComponentDecoratorCall(call)) return;
|
|
1943
|
+
if (!isComponentDecoratorCall(context, call)) return;
|
|
785
1944
|
const metadata = call.arguments?.[0];
|
|
786
1945
|
if (metadata?.type !== "ObjectExpression") return;
|
|
787
1946
|
for (const property of metadata.properties ?? []) {
|
|
@@ -806,11 +1965,117 @@ const preferStyleUrl = defineRule({
|
|
|
806
1965
|
}
|
|
807
1966
|
});
|
|
808
1967
|
//#endregion
|
|
1968
|
+
//#region src/rules/public-component-interface/index.ts
|
|
1969
|
+
const TARGET_DECORATORS = new Set(["Component", "Directive"]);
|
|
1970
|
+
const INPUT_MODEL_APIS = new Set(["input", "model"]);
|
|
1971
|
+
const OUTPUT_APIS = new Set(["output", "outputFromObservable"]);
|
|
1972
|
+
const INJECT_APIS = new Set(["inject"]);
|
|
1973
|
+
const FIELD_NODE_TYPES = new Set([
|
|
1974
|
+
"AccessorProperty",
|
|
1975
|
+
"FieldDefinition",
|
|
1976
|
+
"PropertyDefinition"
|
|
1977
|
+
]);
|
|
1978
|
+
function hasTargetDecorator(context, classNode) {
|
|
1979
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
1980
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, TARGET_DECORATORS));
|
|
1981
|
+
}
|
|
1982
|
+
function isApiCallFromAngularCore(context, node, apiNames) {
|
|
1983
|
+
if (!node || node.type !== "CallExpression") return false;
|
|
1984
|
+
const callee = node.callee;
|
|
1985
|
+
if (isImportedReference(context, callee, "@angular/core", apiNames)) return true;
|
|
1986
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
1987
|
+
const supportsRequiredApi = [...INPUT_MODEL_APIS].some((name) => apiNames.has(name));
|
|
1988
|
+
if (getPropertyName(callee.property) === "required" && callee.object?.type === "Identifier") return supportsRequiredApi && isImportedReference(context, callee.object, "@angular/core", apiNames);
|
|
1989
|
+
if (isImportedNamespaceMember(context, callee, "@angular/core", apiNames)) return true;
|
|
1990
|
+
if (getPropertyName(callee.property) === "required" && callee.object?.type === "MemberExpression" && callee.object.object?.type === "Identifier") {
|
|
1991
|
+
const namespaceApiName = getPropertyName(callee.object.property);
|
|
1992
|
+
return supportsRequiredApi && !!namespaceApiName && apiNames.has(namespaceApiName) && isImportedNamespaceMember(context, callee.object, "@angular/core", apiNames);
|
|
1993
|
+
}
|
|
1994
|
+
return false;
|
|
1995
|
+
}
|
|
1996
|
+
function isNonPublicMember(memberNode) {
|
|
1997
|
+
if (memberNode.key?.type === "PrivateIdentifier") return true;
|
|
1998
|
+
return memberNode.accessibility === "private" || memberNode.accessibility === "protected";
|
|
1999
|
+
}
|
|
2000
|
+
function isPublicMember(memberNode) {
|
|
2001
|
+
if (memberNode.key?.type === "PrivateIdentifier") return false;
|
|
2002
|
+
return memberNode.accessibility !== "private" && memberNode.accessibility !== "protected";
|
|
2003
|
+
}
|
|
2004
|
+
function getAccessibilityModifierRange(context, memberNode) {
|
|
2005
|
+
const memberRange = getRange(memberNode);
|
|
2006
|
+
const keyRange = getRange(memberNode.key);
|
|
2007
|
+
if (!memberRange || !keyRange) return null;
|
|
2008
|
+
const prefixText = context.sourceCode.text.slice(memberRange[0], keyRange[0]);
|
|
2009
|
+
const match = /\b(private|protected|public)\b/u.exec(prefixText);
|
|
2010
|
+
if (!match) return null;
|
|
2011
|
+
return [memberRange[0] + match.index, memberRange[0] + match.index + match[0].length];
|
|
2012
|
+
}
|
|
2013
|
+
function getVisibilityFix(context, memberNode, targetVisibility) {
|
|
2014
|
+
if (memberNode.key?.type === "PrivateIdentifier") return void 0;
|
|
2015
|
+
const keyRange = getRange(memberNode.key);
|
|
2016
|
+
if (!keyRange) return void 0;
|
|
2017
|
+
const accessibilityRange = getAccessibilityModifierRange(context, memberNode);
|
|
2018
|
+
if (accessibilityRange) return (fixer) => fixer.replaceTextRange(accessibilityRange, targetVisibility);
|
|
2019
|
+
return (fixer) => fixer.insertTextBeforeRange([keyRange[0], keyRange[0]], `${targetVisibility} `);
|
|
2020
|
+
}
|
|
2021
|
+
const publicComponentInterface = defineRule({
|
|
2022
|
+
meta: {
|
|
2023
|
+
type: "problem",
|
|
2024
|
+
docs: {
|
|
2025
|
+
description: "Require component/directive signal interface members (input/model/output APIs) to be public, and it's dependencies to be non-public.",
|
|
2026
|
+
recommended: true
|
|
2027
|
+
},
|
|
2028
|
+
fixable: "code",
|
|
2029
|
+
schema: [],
|
|
2030
|
+
messages: {
|
|
2031
|
+
nonPublicInputModel: "Input/model member {{name}} must be public so the component/directive interface is externally accessible.",
|
|
2032
|
+
nonPublicOutput: "Output member {{name}} must be public so the component/directive interface is externally accessible.",
|
|
2033
|
+
publicInjectMember: "Injected member {{name}} should not be public; prefer protected (template access) or private/#private."
|
|
2034
|
+
}
|
|
2035
|
+
},
|
|
2036
|
+
createOnce(context) {
|
|
2037
|
+
return { ClassBody(node) {
|
|
2038
|
+
const classNode = node.parent;
|
|
2039
|
+
if (!hasTargetDecorator(context, classNode)) return;
|
|
2040
|
+
for (const member of node.body ?? []) {
|
|
2041
|
+
if (!FIELD_NODE_TYPES.has(member.type)) continue;
|
|
2042
|
+
const isInputModelMember = isApiCallFromAngularCore(context, member.value, INPUT_MODEL_APIS);
|
|
2043
|
+
const isOutputMember = isApiCallFromAngularCore(context, member.value, OUTPUT_APIS);
|
|
2044
|
+
if (isInputModelMember && isNonPublicMember(member)) {
|
|
2045
|
+
context.report({
|
|
2046
|
+
node: member.key ?? member,
|
|
2047
|
+
messageId: "nonPublicInputModel",
|
|
2048
|
+
data: { name: getPropertyName(member.key) ?? "member" },
|
|
2049
|
+
fix: getVisibilityFix(context, member, "public")
|
|
2050
|
+
});
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
if (isOutputMember && isNonPublicMember(member)) {
|
|
2054
|
+
context.report({
|
|
2055
|
+
node: member.key ?? member,
|
|
2056
|
+
messageId: "nonPublicOutput",
|
|
2057
|
+
data: { name: getPropertyName(member.key) ?? "member" },
|
|
2058
|
+
fix: getVisibilityFix(context, member, "public")
|
|
2059
|
+
});
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
if (isPublicMember(member) && isApiCallFromAngularCore(context, member.value, INJECT_APIS)) context.report({
|
|
2063
|
+
node: member.key ?? member,
|
|
2064
|
+
messageId: "publicInjectMember",
|
|
2065
|
+
data: { name: getPropertyName(member.key) ?? "member" },
|
|
2066
|
+
fix: getVisibilityFix(context, member, "protected")
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
} };
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
//#endregion
|
|
809
2073
|
//#region src/rules/restrict-injectable-provided-in/index.ts
|
|
810
2074
|
const ALLOWED_PROVIDED_IN_VALUES = new Set(["root", "platform"]);
|
|
811
|
-
|
|
2075
|
+
const INJECTABLE_DECORATORS = new Set(["Injectable"]);
|
|
2076
|
+
function getInjectableMetadata(context, node) {
|
|
812
2077
|
if (node.type !== "Decorator") return null;
|
|
813
|
-
if (
|
|
2078
|
+
if (!isAngularCoreDecorator(context, node, INJECTABLE_DECORATORS)) return null;
|
|
814
2079
|
const expression = node.expression;
|
|
815
2080
|
if (expression?.type !== "CallExpression") return null;
|
|
816
2081
|
const metadata = expression.arguments?.[0];
|
|
@@ -832,11 +2097,11 @@ const restrictInjectableProvidedIn = defineRule({
|
|
|
832
2097
|
recommended: true
|
|
833
2098
|
},
|
|
834
2099
|
schema: [],
|
|
835
|
-
messages: { disallowedProvidedIn: "@Injectable providedIn should be 'root' or 'platform', not {{actual}}." }
|
|
2100
|
+
messages: { disallowedProvidedIn: "@Injectable providedIn should be the literal 'root' or 'platform', not {{actual}}." }
|
|
836
2101
|
},
|
|
837
2102
|
createOnce(context) {
|
|
838
2103
|
return { Decorator(node) {
|
|
839
|
-
const metadata = getInjectableMetadata(node);
|
|
2104
|
+
const metadata = getInjectableMetadata(context, node);
|
|
840
2105
|
if (!metadata) return;
|
|
841
2106
|
const providedInProperty = getProvidedInProperty(metadata);
|
|
842
2107
|
if (!providedInProperty) return;
|
|
@@ -878,6 +2143,37 @@ const ANGULAR_CLASS_DECORATOR_NAMES = new Set([
|
|
|
878
2143
|
"NgModule"
|
|
879
2144
|
]);
|
|
880
2145
|
const INJECTION_CONTEXT_RUNNER_NAMES = new Set(["runInInjectionContext", "runInContext"]);
|
|
2146
|
+
const KNOWN_INJECTION_CONTEXT_API_IMPORTS = [
|
|
2147
|
+
{
|
|
2148
|
+
from: "@angular/core",
|
|
2149
|
+
imports: [
|
|
2150
|
+
"afterEveryRender",
|
|
2151
|
+
"afterNextRender",
|
|
2152
|
+
"afterRender",
|
|
2153
|
+
"afterRenderEffect",
|
|
2154
|
+
"assertInInjectionContext",
|
|
2155
|
+
"effect",
|
|
2156
|
+
"inject",
|
|
2157
|
+
"resource"
|
|
2158
|
+
]
|
|
2159
|
+
},
|
|
2160
|
+
{
|
|
2161
|
+
from: "@angular/core/rxjs-interop",
|
|
2162
|
+
imports: [
|
|
2163
|
+
"rxResource",
|
|
2164
|
+
"toObservable",
|
|
2165
|
+
"toSignal"
|
|
2166
|
+
]
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
from: "@angular/common/http",
|
|
2170
|
+
imports: ["httpResource"]
|
|
2171
|
+
},
|
|
2172
|
+
{
|
|
2173
|
+
from: "@angular/forms/signals",
|
|
2174
|
+
imports: ["form"]
|
|
2175
|
+
}
|
|
2176
|
+
];
|
|
881
2177
|
const INJECTION_CONTEXT_FUNCTION_TYPE_NAMES = new Set([
|
|
882
2178
|
"CanActivateFn",
|
|
883
2179
|
"CanActivateChildFn",
|
|
@@ -898,17 +2194,6 @@ const CLASS_FIELD_TYPES = new Set([
|
|
|
898
2194
|
"FieldDefinition",
|
|
899
2195
|
"PropertyDefinition"
|
|
900
2196
|
]);
|
|
901
|
-
function getTypeName(node) {
|
|
902
|
-
if (!node) return null;
|
|
903
|
-
if (node.type === "Identifier") return node.name;
|
|
904
|
-
if (node.type === "TSTypeReference") return getTypeName(node.typeName);
|
|
905
|
-
if (node.type === "TSQualifiedName") {
|
|
906
|
-
const left = getTypeName(node.left);
|
|
907
|
-
const right = getTypeName(node.right);
|
|
908
|
-
return left && right ? `${left}.${right}` : right ?? left;
|
|
909
|
-
}
|
|
910
|
-
return null;
|
|
911
|
-
}
|
|
912
2197
|
function isFunction(node) {
|
|
913
2198
|
return !!node && FUNCTION_TYPES.has(node.type);
|
|
914
2199
|
}
|
|
@@ -975,11 +2260,6 @@ function isTypedInjectionContextFunction(functionNode) {
|
|
|
975
2260
|
const unqualifiedTypeName = typeName.includes(".") ? typeName.split(".").at(-1) : typeName;
|
|
976
2261
|
return INJECTION_CONTEXT_FUNCTION_TYPE_NAMES.has(unqualifiedTypeName ?? typeName);
|
|
977
2262
|
}
|
|
978
|
-
function getNodeStart(node) {
|
|
979
|
-
if (typeof node.start === "number") return node.start;
|
|
980
|
-
if (Array.isArray(node.range) && typeof node.range[0] === "number") return node.range[0];
|
|
981
|
-
return null;
|
|
982
|
-
}
|
|
983
2263
|
function hasAwaitBeforeNodeInFunction(functionNode, node) {
|
|
984
2264
|
const nodeStart = getNodeStart(node);
|
|
985
2265
|
if (nodeStart === null) return false;
|
|
@@ -1027,16 +2307,27 @@ function isAllowedInjectionContext(context, node, allowedFunctionNames, injectFu
|
|
|
1027
2307
|
const functionName = getFunctionName(nearestFunction);
|
|
1028
2308
|
return functionName ? allowedFunctionNames.has(functionName) || injectFunctionPrefixes.some((prefix) => functionName.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => functionName.endsWith(suffix)) : false;
|
|
1029
2309
|
}
|
|
1030
|
-
function
|
|
2310
|
+
function getKnownInjectionContextApiImports(source) {
|
|
2311
|
+
const apiImports = KNOWN_INJECTION_CONTEXT_API_IMPORTS.find((entry) => entry.from === source);
|
|
2312
|
+
return apiImports ? new Set(apiImports.imports) : null;
|
|
2313
|
+
}
|
|
2314
|
+
function isKnownInjectionContextApiCall(context, node, injectionContextApiLocalNames, injectionContextApiNamespaceMembers, checkUnimportedInject) {
|
|
1031
2315
|
const callee = node.callee;
|
|
1032
|
-
if (callee?.type === "Identifier") return
|
|
1033
|
-
|
|
2316
|
+
if (callee?.type === "Identifier") return injectionContextApiLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee) || checkUnimportedInject && callee.name === "inject" && !isShadowedIdentifier(context, callee);
|
|
2317
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
2318
|
+
if (callee.object?.type === "Identifier") {
|
|
2319
|
+
if (injectionContextApiLocalNames.has(callee.object.name) && !isShadowedIdentifier(context, callee.object)) return true;
|
|
2320
|
+
return !!injectionContextApiNamespaceMembers.get(callee.object.name)?.has(getPropertyName(callee.property) ?? "") && !isShadowedIdentifier(context, callee.object);
|
|
2321
|
+
}
|
|
2322
|
+
if (callee.object?.type === "MemberExpression" && callee.object.object?.type === "Identifier") return !!injectionContextApiNamespaceMembers.get(callee.object.object.name)?.has(getPropertyName(callee.object.property) ?? "") && !isShadowedIdentifier(context, callee.object.object);
|
|
2323
|
+
return false;
|
|
1034
2324
|
}
|
|
1035
|
-
function isInjectLikeHelperCall(node,
|
|
2325
|
+
function isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) {
|
|
1036
2326
|
const callee = node.callee;
|
|
1037
2327
|
if (callee?.type !== "Identifier") return false;
|
|
2328
|
+
if (isShadowedIdentifier(context, callee)) return false;
|
|
1038
2329
|
if (runsInInjectionContextFunctionNames.has(callee.name)) return true;
|
|
1039
|
-
return !
|
|
2330
|
+
return !injectionContextApiLocalNames.has(callee.name) && callee.name !== "inject" && (injectFunctionPrefixes.some((prefix) => callee.name.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => callee.name.endsWith(suffix)));
|
|
1040
2331
|
}
|
|
1041
2332
|
//#endregion
|
|
1042
2333
|
//#region src/index.ts
|
|
@@ -1044,19 +2335,24 @@ const plugin = eslintCompatPlugin({
|
|
|
1044
2335
|
meta: { name: "@benjavicente/lint-angular" },
|
|
1045
2336
|
rules: {
|
|
1046
2337
|
"avoid-explicit-injection-context": avoidExplicitInjectionContext,
|
|
2338
|
+
"avoid-explicit-subscription-management": avoidExplicitSubscriptionManagement,
|
|
1047
2339
|
"avoid-ng-modules": avoidNgModules,
|
|
2340
|
+
"avoid-rxjs-state-in-component": avoidRxjsStateInComponent,
|
|
1048
2341
|
"avoid-writing-signals-in-reactive-context": avoidWritingSignalsInReactiveContext,
|
|
1049
2342
|
"class-member-order": classMemberOrder,
|
|
1050
|
-
"
|
|
2343
|
+
"class-matches-filename": classMatchesFilename,
|
|
2344
|
+
"component-resource-filenames": componentResourceFilenames,
|
|
2345
|
+
"decorator-filename-suffix": decoratorFilenameSuffix,
|
|
1051
2346
|
"prefer-load-component-over-load-children": preferLoadComponentOverLoadChildren,
|
|
1052
2347
|
"prefer-private-elements": preferPrivateElements,
|
|
1053
2348
|
"prefer-style-url": preferStyleUrl,
|
|
2349
|
+
"public-component-interface": publicComponentInterface,
|
|
1054
2350
|
"restrict-injectable-provided-in": restrictInjectableProvidedIn,
|
|
1055
2351
|
"rules-of-inject": defineRule({
|
|
1056
2352
|
meta: {
|
|
1057
2353
|
type: "problem",
|
|
1058
2354
|
docs: {
|
|
1059
|
-
description: "Require Angular
|
|
2355
|
+
description: "Require Angular APIs that depend on injection context to appear only in known injection contexts.",
|
|
1060
2356
|
recommended: true
|
|
1061
2357
|
},
|
|
1062
2358
|
schema: [{
|
|
@@ -1100,17 +2396,17 @@ const plugin = eslintCompatPlugin({
|
|
|
1100
2396
|
}
|
|
1101
2397
|
}
|
|
1102
2398
|
}],
|
|
1103
|
-
messages: { disallowedInject: "Angular
|
|
2399
|
+
messages: { disallowedInject: "Angular APIs that depend on injection context must be called from an injection context: a class field initializer or constructor in an Angular-decorated class, provider factory, InjectionToken factory, runInInjectionContext/runInContext callback, Angular route callback property (for example loadComponent/canActivate), an inject* or *Guard function, or configured allowed function." }
|
|
1104
2400
|
},
|
|
1105
2401
|
createOnce(context) {
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
2402
|
+
const injectionContextApiLocalNames = /* @__PURE__ */ new Set();
|
|
2403
|
+
const injectionContextApiNamespaceMembers = /* @__PURE__ */ new Map();
|
|
1108
2404
|
const runsInInjectionContextFunctionNames = /* @__PURE__ */ new Set();
|
|
1109
2405
|
let runsInInjectionContextRules = [];
|
|
1110
2406
|
return {
|
|
1111
2407
|
before() {
|
|
1112
|
-
|
|
1113
|
-
|
|
2408
|
+
injectionContextApiLocalNames.clear();
|
|
2409
|
+
injectionContextApiNamespaceMembers.clear();
|
|
1114
2410
|
runsInInjectionContextFunctionNames.clear();
|
|
1115
2411
|
runsInInjectionContextRules = (context.options[0] ?? {}).runsInInjectionContext ?? DEFAULT_RUNS_IN_INJECTION_CONTEXT;
|
|
1116
2412
|
},
|
|
@@ -1121,10 +2417,10 @@ const plugin = eslintCompatPlugin({
|
|
|
1121
2417
|
if (specifier.type === "ImportSpecifier" && (matchingRule.imports === "all" || matchingRule.imports.includes(getPropertyName(specifier.imported) ?? ""))) runsInInjectionContextFunctionNames.add(specifier.local.name);
|
|
1122
2418
|
if ((specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") && matchingRule.imports === "all") runsInInjectionContextFunctionNames.add(specifier.local.name);
|
|
1123
2419
|
}
|
|
1124
|
-
|
|
1125
|
-
for (const specifier of node.specifiers ?? []) {
|
|
1126
|
-
if (specifier.type === "ImportSpecifier" && getPropertyName(specifier.imported)
|
|
1127
|
-
if (specifier.type === "ImportNamespaceSpecifier")
|
|
2420
|
+
const knownApiImports = typeof source === "string" ? getKnownInjectionContextApiImports(source) : null;
|
|
2421
|
+
if (knownApiImports) for (const specifier of node.specifiers ?? []) {
|
|
2422
|
+
if (specifier.type === "ImportSpecifier" && knownApiImports.has(getPropertyName(specifier.imported) ?? "")) injectionContextApiLocalNames.add(specifier.local.name);
|
|
2423
|
+
if (specifier.type === "ImportNamespaceSpecifier") injectionContextApiNamespaceMembers.set(specifier.local.name, knownApiImports);
|
|
1128
2424
|
}
|
|
1129
2425
|
},
|
|
1130
2426
|
CallExpression(node) {
|
|
@@ -1134,14 +2430,14 @@ const plugin = eslintCompatPlugin({
|
|
|
1134
2430
|
const injectFunctionPrefixes = options.injectFunctionPrefixes ?? DEFAULT_INJECT_FUNCTION_PREFIXES;
|
|
1135
2431
|
const injectFunctionSuffixes = options.injectFunctionSuffixes ?? DEFAULT_INJECT_FUNCTION_SUFFIXES;
|
|
1136
2432
|
const inAllowedContext = isAllowedInjectionContext(context, node, allowedFunctionNames, injectFunctionPrefixes, injectFunctionSuffixes);
|
|
1137
|
-
if (isInjectLikeHelperCall(node,
|
|
2433
|
+
if (isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) && !inAllowedContext) {
|
|
1138
2434
|
context.report({
|
|
1139
2435
|
node: node.callee,
|
|
1140
2436
|
messageId: "disallowedInject"
|
|
1141
2437
|
});
|
|
1142
2438
|
return;
|
|
1143
2439
|
}
|
|
1144
|
-
if (!
|
|
2440
|
+
if (!isKnownInjectionContextApiCall(context, node, injectionContextApiLocalNames, injectionContextApiNamespaceMembers, checkUnimportedInject)) return;
|
|
1145
2441
|
if (inAllowedContext) return;
|
|
1146
2442
|
context.report({
|
|
1147
2443
|
node: node.callee,
|