@benjavicente/lint-angular 0.0.1 → 0.0.2
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 +16 -10
- package/dist/index.mjs +1643 -230
- package/package.json +11 -11
package/dist/index.mjs
CHANGED
|
@@ -23,6 +23,168 @@ 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 addAngularCoreDecoratorImport(specifier, decoratorNames, imports) {
|
|
175
|
+
if (specifier.type === "ImportSpecifier") {
|
|
176
|
+
const importedName = getPropertyName(specifier.imported);
|
|
177
|
+
if (importedName && decoratorNames.has(importedName)) imports.decoratorLocalNames.add(specifier.local.name);
|
|
178
|
+
}
|
|
179
|
+
if (specifier.type === "ImportNamespaceSpecifier") imports.angularNamespaces.add(specifier.local.name);
|
|
180
|
+
}
|
|
181
|
+
function isAngularCoreDecorator(context, decorator, imports) {
|
|
182
|
+
if (!decorator) return false;
|
|
183
|
+
const expression = decorator.expression ?? decorator;
|
|
184
|
+
const callee = expression?.type === "CallExpression" ? expression.callee : expression;
|
|
185
|
+
if (callee?.type === "Identifier") return imports.decoratorLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
186
|
+
return callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && imports.angularNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && imports.decoratorNames.has(getPropertyName(callee.property) ?? "");
|
|
187
|
+
}
|
|
26
188
|
//#endregion
|
|
27
189
|
//#region src/rules/class-member-order/index.ts
|
|
28
190
|
const ANGULAR_CLASS_DECORATOR_NAMES$1 = new Set([
|
|
@@ -45,38 +207,141 @@ const ORDER_LABELS = [
|
|
|
45
207
|
"outputs",
|
|
46
208
|
"everything else"
|
|
47
209
|
];
|
|
48
|
-
function hasAngularClassDecorator(classNode) {
|
|
210
|
+
function hasAngularClassDecorator(context, classNode, imports) {
|
|
49
211
|
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
50
|
-
return classNode.decorators.some((decorator) =>
|
|
212
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, imports));
|
|
51
213
|
}
|
|
52
|
-
function
|
|
53
|
-
if (!node || node.type !== "CallExpression") return
|
|
214
|
+
function isApiCall(context, node, localNames, angularNamespaces, apiNames) {
|
|
215
|
+
if (!node || node.type !== "CallExpression") return false;
|
|
54
216
|
const callee = node.callee;
|
|
55
|
-
if (callee?.type === "Identifier") return callee.name;
|
|
56
|
-
if (callee?.type
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
217
|
+
if (callee?.type === "Identifier") return localNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
218
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
219
|
+
const supportsRequiredApi = [...INPUT_MODEL_CALL_NAMES].some((name) => apiNames.has(name));
|
|
220
|
+
if (callee.object?.type === "Identifier") {
|
|
221
|
+
if (getPropertyName(callee.property) === "required") return supportsRequiredApi && localNames.has(callee.object.name) && !isShadowedIdentifier(context, callee.object);
|
|
222
|
+
return angularNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && apiNames.has(getPropertyName(callee.property) ?? "");
|
|
223
|
+
}
|
|
224
|
+
if (getPropertyName(callee.property) === "required" && callee.object?.type === "MemberExpression" && callee.object.object?.type === "Identifier") return supportsRequiredApi && angularNamespaces.has(callee.object.object.name) && !isShadowedIdentifier(context, callee.object.object) && apiNames.has(getPropertyName(callee.object.property) ?? "");
|
|
225
|
+
return false;
|
|
61
226
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
227
|
+
function hasDecorator(context, element, localNames, angularNamespaces, decoratorNames) {
|
|
228
|
+
return Array.isArray(element.decorators) ? element.decorators.some((decorator) => {
|
|
229
|
+
const expression = decorator.expression ?? decorator;
|
|
230
|
+
const callee = expression?.type === "CallExpression" ? expression.callee : expression;
|
|
231
|
+
if (callee?.type === "Identifier") return localNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
232
|
+
return callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && decoratorNames.has(getPropertyName(callee.property) ?? "");
|
|
233
|
+
}) : false;
|
|
66
234
|
}
|
|
67
|
-
function classifyMember(element) {
|
|
235
|
+
function classifyMember(context, element, imports) {
|
|
68
236
|
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;
|
|
237
|
+
if (isApiCall(context, element.value, imports.injectLocalNames, imports.angularNamespaces, new Set(["inject"]))) return 0;
|
|
238
|
+
if (isApiCall(context, element.value, imports.inputModelLocalNames, imports.angularNamespaces, INPUT_MODEL_CALL_NAMES)) return 1;
|
|
239
|
+
if (hasDecorator(context, element, imports.inputModelDecoratorLocalNames, imports.angularNamespaces, new Set(["Input"]))) return 1;
|
|
240
|
+
if (isApiCall(context, element.value, imports.outputLocalNames, imports.angularNamespaces, OUTPUT_CALL_NAMES)) return 2;
|
|
241
|
+
if (hasDecorator(context, element, imports.outputDecoratorLocalNames, imports.angularNamespaces, new Set(["Output"]))) return 2;
|
|
75
242
|
return 3;
|
|
76
243
|
}
|
|
77
244
|
if (element.type === "MethodDefinition") return 3;
|
|
78
245
|
return 3;
|
|
79
246
|
}
|
|
247
|
+
function getMemberName$1(node) {
|
|
248
|
+
if (!node) return null;
|
|
249
|
+
if (node.type === "Identifier" || node.type === "PrivateIdentifier") return node.name;
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
function collectThisMemberReferences(node, references = /* @__PURE__ */ new Set()) {
|
|
253
|
+
if (!node) return references;
|
|
254
|
+
if (Array.isArray(node)) {
|
|
255
|
+
for (const item of node) collectThisMemberReferences(item, references);
|
|
256
|
+
return references;
|
|
257
|
+
}
|
|
258
|
+
if (node.type === "MemberExpression" && node.object?.type === "ThisExpression") {
|
|
259
|
+
const propertyName = getPropertyName(node.property);
|
|
260
|
+
if (propertyName) references.add(propertyName);
|
|
261
|
+
}
|
|
262
|
+
for (const [key, value] of Object.entries(node)) {
|
|
263
|
+
if (key === "parent") continue;
|
|
264
|
+
if (!value || typeof value !== "object") continue;
|
|
265
|
+
collectThisMemberReferences(value, references);
|
|
266
|
+
}
|
|
267
|
+
return references;
|
|
268
|
+
}
|
|
269
|
+
function applyDependencyGroups(classifiedMembers) {
|
|
270
|
+
const membersByName = /* @__PURE__ */ new Map();
|
|
271
|
+
for (const member of classifiedMembers) if (member.name) membersByName.set(member.name, member);
|
|
272
|
+
let changed = true;
|
|
273
|
+
while (changed) {
|
|
274
|
+
changed = false;
|
|
275
|
+
for (const member of classifiedMembers) for (const dependencyName of member.dependencies) {
|
|
276
|
+
const dependency = membersByName.get(dependencyName);
|
|
277
|
+
if (!dependency) continue;
|
|
278
|
+
if (dependency.effectiveGroup <= member.effectiveGroup) continue;
|
|
279
|
+
dependency.effectiveGroup = member.effectiveGroup;
|
|
280
|
+
changed = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function dependsOn(member, dependency, membersByName, seen = /* @__PURE__ */ new Set()) {
|
|
285
|
+
if (!dependency.name) return false;
|
|
286
|
+
if (member.dependencies.has(dependency.name)) return true;
|
|
287
|
+
if (seen.has(member)) return false;
|
|
288
|
+
seen.add(member);
|
|
289
|
+
for (const dependencyName of member.dependencies) {
|
|
290
|
+
const next = membersByName.get(dependencyName);
|
|
291
|
+
if (next && dependsOn(next, dependency, membersByName, seen)) return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function getSortedMembers(classifiedMembers) {
|
|
296
|
+
const membersByName = /* @__PURE__ */ new Map();
|
|
297
|
+
for (const member of classifiedMembers) if (member.name) membersByName.set(member.name, member);
|
|
298
|
+
return classifiedMembers.toSorted((left, right) => {
|
|
299
|
+
if (dependsOn(left, right, membersByName)) return 1;
|
|
300
|
+
if (dependsOn(right, left, membersByName)) return -1;
|
|
301
|
+
return left.effectiveGroup - right.effectiveGroup;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function hasUnresolvedPrioritizedDependency(member, membersByName) {
|
|
305
|
+
if (member.group === 3) return false;
|
|
306
|
+
for (const dependencyName of member.dependencies) if (!membersByName.has(dependencyName)) return true;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
function getSortedMembersFix(context, classBody, classifiedMembers) {
|
|
310
|
+
if (!classifiedMembers.length) return void 0;
|
|
311
|
+
if ((classBody.body ?? []).length !== classifiedMembers.length) return void 0;
|
|
312
|
+
const sortableRanges = [];
|
|
313
|
+
const membersByName = /* @__PURE__ */ new Map();
|
|
314
|
+
for (const member of classifiedMembers) if (member.name) membersByName.set(member.name, member);
|
|
315
|
+
for (const member of classifiedMembers) {
|
|
316
|
+
const { element } = member;
|
|
317
|
+
if (!CLASS_FIELD_TYPES$1.has(element.type)) return void 0;
|
|
318
|
+
if (element.type === "AccessorProperty") return void 0;
|
|
319
|
+
if (element.computed) return void 0;
|
|
320
|
+
if (Array.isArray(element.decorators) && element.decorators.length > 0) return void 0;
|
|
321
|
+
if (hasUnresolvedPrioritizedDependency(member, membersByName)) return;
|
|
322
|
+
const range = getRange(element);
|
|
323
|
+
if (!range) return void 0;
|
|
324
|
+
if (!context.sourceCode.text.slice(range[0], range[1]).trimEnd().endsWith(";")) return;
|
|
325
|
+
sortableRanges.push(range);
|
|
326
|
+
}
|
|
327
|
+
const sortedMembers = getSortedMembers(classifiedMembers);
|
|
328
|
+
if (sortedMembers.every((member, index) => member.element === classifiedMembers[index]?.element)) return void 0;
|
|
329
|
+
return (fixer) => {
|
|
330
|
+
const sourceText = context.sourceCode.text;
|
|
331
|
+
const sortedTexts = sortedMembers.map(({ element }) => {
|
|
332
|
+
const range = getRange(element);
|
|
333
|
+
return range ? sourceText.slice(range[0], range[1]) : "";
|
|
334
|
+
});
|
|
335
|
+
let output = "";
|
|
336
|
+
for (let index = 0; index < sortableRanges.length; index += 1) {
|
|
337
|
+
const [, end] = sortableRanges[index];
|
|
338
|
+
const nextStart = sortableRanges[index + 1]?.[0];
|
|
339
|
+
output += sortedTexts[index];
|
|
340
|
+
output += sourceText.slice(end, nextStart ?? end);
|
|
341
|
+
}
|
|
342
|
+
return fixer.replaceTextRange([sortableRanges[0][0], sortableRanges.at(-1)[1]], output);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
80
345
|
const classMemberOrder = defineRule({
|
|
81
346
|
meta: {
|
|
82
347
|
type: "suggestion",
|
|
@@ -84,30 +349,88 @@ const classMemberOrder = defineRule({
|
|
|
84
349
|
description: "Require Angular class members to be ordered as inject fields, inputs/models, outputs, then everything else.",
|
|
85
350
|
recommended: true
|
|
86
351
|
},
|
|
352
|
+
fixable: "code",
|
|
87
353
|
schema: [],
|
|
88
354
|
messages: { outOfOrder: "Angular class member should be ordered before {{previousGroup}} and with {{expectedGroup}}." }
|
|
89
355
|
},
|
|
90
356
|
createOnce(context) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
357
|
+
const imports = {
|
|
358
|
+
decoratorNames: ANGULAR_CLASS_DECORATOR_NAMES$1,
|
|
359
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
360
|
+
injectLocalNames: /* @__PURE__ */ new Set(),
|
|
361
|
+
inputModelLocalNames: /* @__PURE__ */ new Set(),
|
|
362
|
+
inputModelDecoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
363
|
+
outputLocalNames: /* @__PURE__ */ new Set(),
|
|
364
|
+
outputDecoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
365
|
+
angularNamespaces: /* @__PURE__ */ new Set()
|
|
366
|
+
};
|
|
367
|
+
return {
|
|
368
|
+
before() {
|
|
369
|
+
imports.decoratorLocalNames.clear();
|
|
370
|
+
imports.injectLocalNames.clear();
|
|
371
|
+
imports.inputModelLocalNames.clear();
|
|
372
|
+
imports.inputModelDecoratorLocalNames.clear();
|
|
373
|
+
imports.outputLocalNames.clear();
|
|
374
|
+
imports.outputDecoratorLocalNames.clear();
|
|
375
|
+
imports.angularNamespaces.clear();
|
|
376
|
+
},
|
|
377
|
+
ImportDeclaration(node) {
|
|
378
|
+
if (node.source?.value !== "@angular/core") return;
|
|
379
|
+
for (const specifier of node.specifiers ?? []) {
|
|
380
|
+
addAngularCoreDecoratorImport(specifier, ANGULAR_CLASS_DECORATOR_NAMES$1, imports);
|
|
381
|
+
if (specifier.type === "ImportSpecifier") {
|
|
382
|
+
const importedName = getPropertyName(specifier.imported);
|
|
383
|
+
if (importedName === "inject") imports.injectLocalNames.add(specifier.local.name);
|
|
384
|
+
if (importedName && INPUT_MODEL_CALL_NAMES.has(importedName)) imports.inputModelLocalNames.add(specifier.local.name);
|
|
385
|
+
if (importedName === "Input") imports.inputModelDecoratorLocalNames.add(specifier.local.name);
|
|
386
|
+
if (importedName && OUTPUT_CALL_NAMES.has(importedName)) imports.outputLocalNames.add(specifier.local.name);
|
|
387
|
+
if (importedName === "Output") imports.outputDecoratorLocalNames.add(specifier.local.name);
|
|
388
|
+
}
|
|
389
|
+
if (specifier.type === "ImportNamespaceSpecifier") imports.angularNamespaces.add(specifier.local.name);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
ClassBody(node) {
|
|
393
|
+
const classBody = node;
|
|
394
|
+
if (!hasAngularClassDecorator(context, classBody.parent, imports)) return;
|
|
395
|
+
let highestSeen = null;
|
|
396
|
+
const classifiedMembers = [];
|
|
397
|
+
const outOfOrderReports = [];
|
|
398
|
+
for (const element of classBody.body ?? []) {
|
|
399
|
+
const group = classifyMember(context, element, imports);
|
|
400
|
+
if (group === null) continue;
|
|
401
|
+
classifiedMembers.push({
|
|
402
|
+
element,
|
|
403
|
+
group,
|
|
404
|
+
effectiveGroup: group,
|
|
405
|
+
name: getMemberName$1(element.key),
|
|
406
|
+
dependencies: collectThisMemberReferences(element.value)
|
|
105
407
|
});
|
|
106
|
-
continue;
|
|
107
408
|
}
|
|
108
|
-
|
|
409
|
+
applyDependencyGroups(classifiedMembers);
|
|
410
|
+
for (const { element, effectiveGroup } of classifiedMembers) {
|
|
411
|
+
if (highestSeen !== null && effectiveGroup < highestSeen) {
|
|
412
|
+
outOfOrderReports.push({
|
|
413
|
+
element,
|
|
414
|
+
effectiveGroup,
|
|
415
|
+
previousEffectiveGroup: highestSeen
|
|
416
|
+
});
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
highestSeen = effectiveGroup;
|
|
420
|
+
}
|
|
421
|
+
if (!outOfOrderReports.length) return;
|
|
422
|
+
const fix = getSortedMembersFix(context, classBody, classifiedMembers);
|
|
423
|
+
for (const [index, report] of outOfOrderReports.entries()) context.report({
|
|
424
|
+
node: report.element,
|
|
425
|
+
messageId: "outOfOrder",
|
|
426
|
+
data: {
|
|
427
|
+
expectedGroup: ORDER_LABELS[report.effectiveGroup],
|
|
428
|
+
previousGroup: ORDER_LABELS[report.previousEffectiveGroup]
|
|
429
|
+
},
|
|
430
|
+
fix: index === 0 ? fix : void 0
|
|
431
|
+
});
|
|
109
432
|
}
|
|
110
|
-
}
|
|
433
|
+
};
|
|
111
434
|
}
|
|
112
435
|
});
|
|
113
436
|
//#endregion
|
|
@@ -115,24 +438,24 @@ const classMemberOrder = defineRule({
|
|
|
115
438
|
const DEFAULT_DISALLOW_INJECT_INJECTOR = true;
|
|
116
439
|
const DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT = true;
|
|
117
440
|
const RUN_IN_INJECTION_CONTEXT_NAMES = new Set(["runInInjectionContext", "runInContext"]);
|
|
118
|
-
function isAngularNamespaceMember(node, namespaces, memberName) {
|
|
119
|
-
return node?.type === "MemberExpression" && node.object?.type === "Identifier" && namespaces.has(node.object.name) && getPropertyName(node.property) === memberName;
|
|
441
|
+
function isAngularNamespaceMember(context, node, namespaces, memberName) {
|
|
442
|
+
return node?.type === "MemberExpression" && node.object?.type === "Identifier" && namespaces.has(node.object.name) && !isShadowedIdentifier(context, node.object) && getPropertyName(node.property) === memberName;
|
|
120
443
|
}
|
|
121
|
-
function isInjectCall(callNode, injectNames, angularNamespaces) {
|
|
444
|
+
function isInjectCall(context, callNode, injectNames, angularNamespaces) {
|
|
122
445
|
const callee = callNode.callee;
|
|
123
|
-
return callee?.type === "Identifier" && injectNames.has(callee.name) || isAngularNamespaceMember(callee, angularNamespaces, "inject");
|
|
446
|
+
return callee?.type === "Identifier" && injectNames.has(callee.name) && !isShadowedIdentifier(context, callee) || isAngularNamespaceMember(context, callee, angularNamespaces, "inject");
|
|
124
447
|
}
|
|
125
|
-
function isInjectorReference(node, injectorNames, angularNamespaces) {
|
|
126
|
-
return node?.type === "Identifier" && injectorNames.has(node.name) || isAngularNamespaceMember(node, angularNamespaces, "Injector");
|
|
448
|
+
function isInjectorReference(context, node, injectorNames, angularNamespaces) {
|
|
449
|
+
return node?.type === "Identifier" && injectorNames.has(node.name) && !isShadowedIdentifier(context, node) || isAngularNamespaceMember(context, node, angularNamespaces, "Injector");
|
|
127
450
|
}
|
|
128
|
-
function isDisallowedInjectInjector(callNode, injectNames, injectorNames, angularNamespaces) {
|
|
129
|
-
if (!isInjectCall(callNode, injectNames, angularNamespaces)) return false;
|
|
130
|
-
return isInjectorReference(callNode.arguments?.[0], injectorNames, angularNamespaces);
|
|
451
|
+
function isDisallowedInjectInjector(context, callNode, injectNames, injectorNames, angularNamespaces) {
|
|
452
|
+
if (!isInjectCall(context, callNode, injectNames, angularNamespaces)) return false;
|
|
453
|
+
return isInjectorReference(context, callNode.arguments?.[0], injectorNames, angularNamespaces);
|
|
131
454
|
}
|
|
132
|
-
function isDisallowedRunInInjectionContext(callNode, runInInjectionContextNames, runInContextNames, angularNamespaces) {
|
|
455
|
+
function isDisallowedRunInInjectionContext(context, callNode, runInInjectionContextNames, runInContextNames, angularNamespaces) {
|
|
133
456
|
const callee = callNode.callee;
|
|
134
|
-
if (callee?.type === "Identifier") return runInInjectionContextNames.has(callee.name) || runInContextNames.has(callee.name);
|
|
135
|
-
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name)) return RUN_IN_INJECTION_CONTEXT_NAMES.has(getPropertyName(callee.property) ?? "");
|
|
457
|
+
if (callee?.type === "Identifier") return (runInInjectionContextNames.has(callee.name) || runInContextNames.has(callee.name)) && !isShadowedIdentifier(context, callee);
|
|
458
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object)) return RUN_IN_INJECTION_CONTEXT_NAMES.has(getPropertyName(callee.property) ?? "");
|
|
136
459
|
return false;
|
|
137
460
|
}
|
|
138
461
|
const avoidExplicitInjectionContext = defineRule({
|
|
@@ -193,11 +516,11 @@ const avoidExplicitInjectionContext = defineRule({
|
|
|
193
516
|
const options = context.options[0] ?? {};
|
|
194
517
|
const disallowInjectInjector = options.disallowInjectInjector ?? DEFAULT_DISALLOW_INJECT_INJECTOR;
|
|
195
518
|
const disallowRunInInjectionContext = options.disallowRunInInjectionContext ?? DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT;
|
|
196
|
-
if (disallowInjectInjector && isDisallowedInjectInjector(callNode, injectNames, injectorNames, angularNamespaces)) context.report({
|
|
519
|
+
if (disallowInjectInjector && isDisallowedInjectInjector(context, callNode, injectNames, injectorNames, angularNamespaces)) context.report({
|
|
197
520
|
node: callNode.callee,
|
|
198
521
|
messageId: "avoidInjectInjector"
|
|
199
522
|
});
|
|
200
|
-
if (disallowRunInInjectionContext && isDisallowedRunInInjectionContext(callNode, runInInjectionContextNames, runInContextNames, angularNamespaces)) context.report({
|
|
523
|
+
if (disallowRunInInjectionContext && isDisallowedRunInInjectionContext(context, callNode, runInInjectionContextNames, runInContextNames, angularNamespaces)) context.report({
|
|
201
524
|
node: callNode.callee,
|
|
202
525
|
messageId: "avoidRunInInjectionContext"
|
|
203
526
|
});
|
|
@@ -206,13 +529,391 @@ const avoidExplicitInjectionContext = defineRule({
|
|
|
206
529
|
}
|
|
207
530
|
});
|
|
208
531
|
//#endregion
|
|
532
|
+
//#region src/rules/avoid-explicit-subscription-management/index.ts
|
|
533
|
+
const TARGET_DECORATORS$2 = new Set([
|
|
534
|
+
"Component",
|
|
535
|
+
"Directive",
|
|
536
|
+
"Injectable"
|
|
537
|
+
]);
|
|
538
|
+
const FIELD_NODE_TYPES$2 = new Set([
|
|
539
|
+
"AccessorProperty",
|
|
540
|
+
"FieldDefinition",
|
|
541
|
+
"PropertyDefinition"
|
|
542
|
+
]);
|
|
543
|
+
const RXJS_SUBSCRIBABLE_NAMES = new Set([
|
|
544
|
+
"AsyncSubject",
|
|
545
|
+
"BehaviorSubject",
|
|
546
|
+
"Observable",
|
|
547
|
+
"ReplaySubject",
|
|
548
|
+
"Subject"
|
|
549
|
+
]);
|
|
550
|
+
function hasTargetDecorator$2(context, classNode, decoratorImports) {
|
|
551
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
552
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, decoratorImports));
|
|
553
|
+
}
|
|
554
|
+
function hasSubscriptionTypeReference(context, node, subscriptionLocalNames, rxjsNamespaces) {
|
|
555
|
+
if (!node) return false;
|
|
556
|
+
if (node.type === "TSTypeReference") {
|
|
557
|
+
const typeName = getTypeName(node.typeName);
|
|
558
|
+
if (node.typeName?.type === "Identifier" && typeName && subscriptionLocalNames.has(typeName) && !isShadowedIdentifier(context, node.typeName)) return true;
|
|
559
|
+
if (typeName?.includes(".")) {
|
|
560
|
+
const [namespaceName, memberName] = typeName.split(".");
|
|
561
|
+
return memberName === "Subscription" && rxjsNamespaces.has(namespaceName);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const [key, value] of Object.entries(node)) {
|
|
565
|
+
if (key === "parent") continue;
|
|
566
|
+
if (!value || typeof value !== "object") continue;
|
|
567
|
+
if (Array.isArray(value)) {
|
|
568
|
+
if (value.some((child) => hasSubscriptionTypeReference(context, child, subscriptionLocalNames, rxjsNamespaces))) return true;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (hasSubscriptionTypeReference(context, value, subscriptionLocalNames, rxjsNamespaces)) return true;
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
function isSubscriptionConstructor(context, node, subscriptionLocalNames, rxjsNamespaces) {
|
|
576
|
+
const expression = unwrapExpression(node);
|
|
577
|
+
if (expression?.type !== "NewExpression") return false;
|
|
578
|
+
const callee = unwrapExpression(expression.callee);
|
|
579
|
+
if (callee?.type === "Identifier") return subscriptionLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
580
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
581
|
+
return callee.object?.type === "Identifier" && rxjsNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && getPropertyName(callee.property) === "Subscription";
|
|
582
|
+
}
|
|
583
|
+
function getReferenceName(node) {
|
|
584
|
+
const expression = unwrapExpression(node);
|
|
585
|
+
if (!expression) return null;
|
|
586
|
+
if (expression.type === "Identifier") return expression.name;
|
|
587
|
+
if (expression.type !== "MemberExpression") return null;
|
|
588
|
+
if (expression.object?.type !== "ThisExpression") return null;
|
|
589
|
+
return getPropertyName(expression.property);
|
|
590
|
+
}
|
|
591
|
+
function isObservableReferenceName(name) {
|
|
592
|
+
return name.length > 1 && name.endsWith("$");
|
|
593
|
+
}
|
|
594
|
+
function addTrackedReference(references, name, binding) {
|
|
595
|
+
const bindings = references.get(name) ?? /* @__PURE__ */ new Set();
|
|
596
|
+
if (binding) bindings.add(binding);
|
|
597
|
+
references.set(name, bindings);
|
|
598
|
+
}
|
|
599
|
+
function hasTrackedReferenceName(references, name) {
|
|
600
|
+
return references.has(name);
|
|
601
|
+
}
|
|
602
|
+
function isTrackedReferenceExpression(context, node, references) {
|
|
603
|
+
const expression = unwrapExpression(node);
|
|
604
|
+
if (!expression) return false;
|
|
605
|
+
const referenceName = getReferenceName(expression);
|
|
606
|
+
if (!referenceName) return false;
|
|
607
|
+
const bindings = references.get(referenceName);
|
|
608
|
+
if (!bindings) return false;
|
|
609
|
+
if (expression.type === "MemberExpression" && expression.object?.type === "ThisExpression") return true;
|
|
610
|
+
if (expression.type !== "Identifier") return false;
|
|
611
|
+
const nearestBinding = findNearestBindingIdentifier(context, expression);
|
|
612
|
+
return !!nearestBinding && bindings.has(nearestBinding);
|
|
613
|
+
}
|
|
614
|
+
function getDeclaredName(node) {
|
|
615
|
+
if (!node) return null;
|
|
616
|
+
if (node.type === "Identifier") return node.name;
|
|
617
|
+
if (node.type === "PrivateIdentifier") return node.name;
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
function hasRxjsSubscribableTypeReference(context, node, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
621
|
+
if (!node) return false;
|
|
622
|
+
if (node.type === "TSTypeReference") {
|
|
623
|
+
const typeName = getTypeName(node.typeName);
|
|
624
|
+
if (node.typeName?.type === "Identifier" && typeName && rxjsSubscribableLocalNames.has(typeName) && !isShadowedIdentifier(context, node.typeName)) return true;
|
|
625
|
+
if (typeName?.includes(".")) {
|
|
626
|
+
const [namespaceName, memberName] = typeName.split(".");
|
|
627
|
+
return RXJS_SUBSCRIBABLE_NAMES.has(memberName) && rxjsNamespaces.has(namespaceName);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (const [key, value] of Object.entries(node)) {
|
|
631
|
+
if (key === "parent") continue;
|
|
632
|
+
if (!value || typeof value !== "object") continue;
|
|
633
|
+
if (Array.isArray(value)) {
|
|
634
|
+
if (value.some((child) => hasRxjsSubscribableTypeReference(context, child, rxjsSubscribableLocalNames, rxjsNamespaces))) return true;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (hasRxjsSubscribableTypeReference(context, value, rxjsSubscribableLocalNames, rxjsNamespaces)) return true;
|
|
638
|
+
}
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
function isRxjsSubscribableConstructor(context, node, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
642
|
+
const expression = unwrapExpression(node);
|
|
643
|
+
if (expression?.type !== "NewExpression") return false;
|
|
644
|
+
const callee = unwrapExpression(expression.callee);
|
|
645
|
+
if (callee?.type === "Identifier") return rxjsSubscribableLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
646
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
647
|
+
return callee.object?.type === "Identifier" && rxjsNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && RXJS_SUBSCRIBABLE_NAMES.has(getPropertyName(callee.property) ?? "");
|
|
648
|
+
}
|
|
649
|
+
function isTakeUntilDestroyedCall(node, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
650
|
+
const callNode = unwrapExpression(node);
|
|
651
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
652
|
+
const callee = unwrapExpression(callNode.callee);
|
|
653
|
+
if (callee?.type === "Identifier") return takeUntilDestroyedLocalNames.has(callee.name);
|
|
654
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
655
|
+
return callee.object?.type === "Identifier" && interopNamespaces.has(callee.object.name) && getPropertyName(callee.property) === "takeUntilDestroyed";
|
|
656
|
+
}
|
|
657
|
+
function isTakeUntilDestroyedSubscribe(node, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
658
|
+
const callNode = unwrapExpression(node);
|
|
659
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
660
|
+
const callee = unwrapExpression(callNode.callee);
|
|
661
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
662
|
+
if (getPropertyName(callee.property) !== "subscribe") return false;
|
|
663
|
+
const source = unwrapExpression(callee.object);
|
|
664
|
+
if (source?.type !== "CallExpression") return false;
|
|
665
|
+
const sourceCallee = unwrapExpression(source.callee);
|
|
666
|
+
if (sourceCallee?.type !== "MemberExpression") return false;
|
|
667
|
+
if (getPropertyName(sourceCallee.property) !== "pipe") return false;
|
|
668
|
+
return (source.arguments ?? []).some((argument) => isTakeUntilDestroyedCall(argument, takeUntilDestroyedLocalNames, interopNamespaces));
|
|
669
|
+
}
|
|
670
|
+
function isKnownRxjsSubscribableExpression(context, node, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
671
|
+
const expression = unwrapExpression(node);
|
|
672
|
+
if (!expression) return false;
|
|
673
|
+
const referenceName = getReferenceName(expression);
|
|
674
|
+
if (referenceName && isObservableReferenceName(referenceName)) return true;
|
|
675
|
+
if (isTrackedReferenceExpression(context, expression, rxjsSubscribableReferences)) return true;
|
|
676
|
+
if (isRxjsSubscribableConstructor(context, expression, rxjsSubscribableLocalNames, rxjsNamespaces)) return true;
|
|
677
|
+
if (expression.type !== "CallExpression") return false;
|
|
678
|
+
const callee = unwrapExpression(expression.callee);
|
|
679
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
680
|
+
const methodName = getPropertyName(callee.property);
|
|
681
|
+
if (methodName !== "asObservable" && methodName !== "pipe") return false;
|
|
682
|
+
return isKnownRxjsSubscribableExpression(context, callee.object, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces);
|
|
683
|
+
}
|
|
684
|
+
function isUnmanagedSubscribeCall(context, node, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
685
|
+
const callNode = unwrapExpression(node);
|
|
686
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
687
|
+
const callee = unwrapExpression(callNode.callee);
|
|
688
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
689
|
+
if (getPropertyName(callee.property) !== "subscribe") return false;
|
|
690
|
+
return isKnownRxjsSubscribableExpression(context, callee.object, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces) && !isTakeUntilDestroyedSubscribe(node, takeUntilDestroyedLocalNames, interopNamespaces);
|
|
691
|
+
}
|
|
692
|
+
function walkNode$1(node, containingClass, visitor) {
|
|
693
|
+
if (!node) return;
|
|
694
|
+
if (Array.isArray(node)) {
|
|
695
|
+
for (const child of node) walkNode$1(child, containingClass, visitor);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (node !== containingClass && (node.type === "ClassDeclaration" || node.type === "ClassExpression")) return;
|
|
699
|
+
if (visitor(node) === false) return;
|
|
700
|
+
for (const [key, value] of Object.entries(node)) {
|
|
701
|
+
if (key === "parent") continue;
|
|
702
|
+
if (!value || typeof value !== "object") continue;
|
|
703
|
+
walkNode$1(value, containingClass, visitor);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function containsUnmanagedSubscribeCall(context, node, containingClass, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
707
|
+
let found = false;
|
|
708
|
+
walkNode$1(node, containingClass, (current) => {
|
|
709
|
+
if (isUnmanagedSubscribeCall(context, current, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
|
|
710
|
+
found = true;
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
return found;
|
|
715
|
+
}
|
|
716
|
+
function collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSubscribableLocalNames, rxjsNamespaces) {
|
|
717
|
+
const references = /* @__PURE__ */ new Map();
|
|
718
|
+
let changed = true;
|
|
719
|
+
while (changed) {
|
|
720
|
+
changed = false;
|
|
721
|
+
walkNode$1(classBody, classNode, (node) => {
|
|
722
|
+
if (FIELD_NODE_TYPES$2.has(node.type)) {
|
|
723
|
+
const name = getDeclaredName(node.key);
|
|
724
|
+
if (!name || hasTrackedReferenceName(references, name)) return;
|
|
725
|
+
if (isObservableReferenceName(name) || hasRxjsSubscribableTypeReference(context, node.typeAnnotation?.typeAnnotation, rxjsSubscribableLocalNames, rxjsNamespaces) || isRxjsSubscribableConstructor(context, node.value, rxjsSubscribableLocalNames, rxjsNamespaces) || isKnownRxjsSubscribableExpression(context, node.value, references, rxjsSubscribableLocalNames, rxjsNamespaces)) {
|
|
726
|
+
addTrackedReference(references, name, node.key);
|
|
727
|
+
changed = true;
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier") {
|
|
732
|
+
if (hasTrackedReferenceName(references, node.id.name)) return;
|
|
733
|
+
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)) {
|
|
734
|
+
addTrackedReference(references, node.id.name, node.id);
|
|
735
|
+
changed = true;
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (node.type === "AssignmentExpression") {
|
|
740
|
+
const name = getReferenceName(node.left);
|
|
741
|
+
if (!name || hasTrackedReferenceName(references, name)) return;
|
|
742
|
+
if (isObservableReferenceName(name) || isRxjsSubscribableConstructor(context, node.right, rxjsSubscribableLocalNames, rxjsNamespaces) || isKnownRxjsSubscribableExpression(context, node.right, references, rxjsSubscribableLocalNames, rxjsNamespaces)) {
|
|
743
|
+
addTrackedReference(references, name, findNearestBindingIdentifier(context, node.left));
|
|
744
|
+
changed = true;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
return references;
|
|
750
|
+
}
|
|
751
|
+
function collectSubscriptionReferences(context, classBody, classNode, subscriptionLocalNames, rxjsNamespaces, rxjsSubscribableReferences, rxjsSubscribableLocalNames, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
752
|
+
const references = /* @__PURE__ */ new Map();
|
|
753
|
+
walkNode$1(classBody, classNode, (node) => {
|
|
754
|
+
if (FIELD_NODE_TYPES$2.has(node.type)) {
|
|
755
|
+
const name = getDeclaredName(node.key);
|
|
756
|
+
if (!name) return;
|
|
757
|
+
if (hasSubscriptionTypeReference(context, node.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces) || isSubscriptionConstructor(context, node.value, subscriptionLocalNames, rxjsNamespaces)) addTrackedReference(references, name, node.key);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (node.type === "VariableDeclarator" && node.id?.type === "Identifier") {
|
|
761
|
+
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);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (node.type === "AssignmentExpression") {
|
|
765
|
+
const name = getReferenceName(node.left);
|
|
766
|
+
if (!name) return;
|
|
767
|
+
if (isSubscriptionConstructor(context, node.right, subscriptionLocalNames, rxjsNamespaces) || isUnmanagedSubscribeCall(context, node.right, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) addTrackedReference(references, name, findNearestBindingIdentifier(context, node.left));
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
return references;
|
|
771
|
+
}
|
|
772
|
+
function isSubscriptionAddCall(context, node, containingClass, subscriptionReferences, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces) {
|
|
773
|
+
const callNode = unwrapExpression(node);
|
|
774
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
775
|
+
const callee = unwrapExpression(callNode.callee);
|
|
776
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
777
|
+
if (getPropertyName(callee.property) !== "add") return false;
|
|
778
|
+
if (isTrackedReferenceExpression(context, callee.object, subscriptionReferences)) return true;
|
|
779
|
+
return (callNode.arguments ?? []).some((argument) => isTrackedReferenceExpression(context, argument, subscriptionReferences) || containsUnmanagedSubscribeCall(context, argument, containingClass, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces));
|
|
780
|
+
}
|
|
781
|
+
function isSubscriptionUnsubscribeCall(context, node, subscriptionReferences) {
|
|
782
|
+
const callNode = unwrapExpression(node);
|
|
783
|
+
if (callNode?.type !== "CallExpression") return false;
|
|
784
|
+
const callee = unwrapExpression(callNode.callee);
|
|
785
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
786
|
+
if (getPropertyName(callee.property) !== "unsubscribe") return false;
|
|
787
|
+
return isTrackedReferenceExpression(context, callee.object, subscriptionReferences);
|
|
788
|
+
}
|
|
789
|
+
const avoidExplicitSubscriptionManagement = defineRule({
|
|
790
|
+
meta: {
|
|
791
|
+
type: "suggestion",
|
|
792
|
+
docs: {
|
|
793
|
+
description: "Avoid manual RxJS Subscription lifecycle management in Angular components, directives, and services.",
|
|
794
|
+
recommended: true
|
|
795
|
+
},
|
|
796
|
+
schema: [],
|
|
797
|
+
messages: {
|
|
798
|
+
explicitSubscriptionType: "Avoid storing RxJS Subscription references in Angular classes. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
799
|
+
explicitSubscriptionConstructor: "Avoid creating RxJS Subscription instances in Angular classes. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
800
|
+
explicitSubscribe: "Avoid subscribe() calls that require manual lifecycle management. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
801
|
+
explicitSubscriptionAdd: "Avoid adding subscriptions to a Subscription container. Prefer takeUntilDestroyed, toSignal, or firstValueFrom.",
|
|
802
|
+
explicitUnsubscribe: "Avoid manual unsubscribe() calls in Angular classes. Prefer takeUntilDestroyed, toSignal, or firstValueFrom."
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
createOnce(context) {
|
|
806
|
+
const subscriptionLocalNames = /* @__PURE__ */ new Set();
|
|
807
|
+
const rxjsSubscribableLocalNames = /* @__PURE__ */ new Set();
|
|
808
|
+
const rxjsNamespaces = /* @__PURE__ */ new Set();
|
|
809
|
+
const takeUntilDestroyedLocalNames = /* @__PURE__ */ new Set();
|
|
810
|
+
const interopNamespaces = /* @__PURE__ */ new Set();
|
|
811
|
+
const decoratorImports = {
|
|
812
|
+
decoratorNames: TARGET_DECORATORS$2,
|
|
813
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
814
|
+
angularNamespaces: /* @__PURE__ */ new Set()
|
|
815
|
+
};
|
|
816
|
+
return {
|
|
817
|
+
before() {
|
|
818
|
+
subscriptionLocalNames.clear();
|
|
819
|
+
rxjsSubscribableLocalNames.clear();
|
|
820
|
+
rxjsNamespaces.clear();
|
|
821
|
+
takeUntilDestroyedLocalNames.clear();
|
|
822
|
+
interopNamespaces.clear();
|
|
823
|
+
decoratorImports.decoratorLocalNames.clear();
|
|
824
|
+
decoratorImports.angularNamespaces.clear();
|
|
825
|
+
},
|
|
826
|
+
ImportDeclaration(node) {
|
|
827
|
+
const source = node.source?.value;
|
|
828
|
+
if (source === "@angular/core") for (const specifier of node.specifiers ?? []) addAngularCoreDecoratorImport(specifier, TARGET_DECORATORS$2, decoratorImports);
|
|
829
|
+
if (source === "rxjs") for (const specifier of node.specifiers ?? []) {
|
|
830
|
+
if (specifier.type === "ImportSpecifier") {
|
|
831
|
+
const importedName = getPropertyName(specifier.imported);
|
|
832
|
+
if (importedName === "Subscription") subscriptionLocalNames.add(specifier.local.name);
|
|
833
|
+
if (RXJS_SUBSCRIBABLE_NAMES.has(importedName ?? "")) rxjsSubscribableLocalNames.add(specifier.local.name);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (specifier.type === "ImportNamespaceSpecifier") rxjsNamespaces.add(specifier.local.name);
|
|
837
|
+
}
|
|
838
|
+
if (source === "@angular/core/rxjs-interop") for (const specifier of node.specifiers ?? []) {
|
|
839
|
+
if (specifier.type === "ImportSpecifier") {
|
|
840
|
+
if (getPropertyName(specifier.imported) === "takeUntilDestroyed") takeUntilDestroyedLocalNames.add(specifier.local.name);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (specifier.type === "ImportNamespaceSpecifier") interopNamespaces.add(specifier.local.name);
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
ClassBody(node) {
|
|
847
|
+
const classBody = node;
|
|
848
|
+
const classNode = classBody.parent;
|
|
849
|
+
if (!classNode || !hasTargetDecorator$2(context, classNode, decoratorImports)) return;
|
|
850
|
+
const rxjsSubscribableReferences = collectRxjsSubscribableReferences(context, classBody, classNode, rxjsSubscribableLocalNames, rxjsNamespaces);
|
|
851
|
+
const subscriptionReferences = collectSubscriptionReferences(context, classBody, classNode, subscriptionLocalNames, rxjsNamespaces, rxjsSubscribableReferences, rxjsSubscribableLocalNames, takeUntilDestroyedLocalNames, interopNamespaces);
|
|
852
|
+
walkNode$1(classBody, classNode, (current) => {
|
|
853
|
+
if (FIELD_NODE_TYPES$2.has(current.type)) {
|
|
854
|
+
if (hasSubscriptionTypeReference(context, current.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces)) context.report({
|
|
855
|
+
node: current.key ?? current,
|
|
856
|
+
messageId: "explicitSubscriptionType"
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (current.type === "VariableDeclarator" && current.id?.type === "Identifier" && hasSubscriptionTypeReference(context, current.id.typeAnnotation?.typeAnnotation, subscriptionLocalNames, rxjsNamespaces)) {
|
|
861
|
+
context.report({
|
|
862
|
+
node: current.id,
|
|
863
|
+
messageId: "explicitSubscriptionType"
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (isSubscriptionConstructor(context, current, subscriptionLocalNames, rxjsNamespaces)) {
|
|
868
|
+
context.report({
|
|
869
|
+
node: current,
|
|
870
|
+
messageId: "explicitSubscriptionConstructor"
|
|
871
|
+
});
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
if (isSubscriptionAddCall(context, current, classNode, subscriptionReferences, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
|
|
875
|
+
context.report({
|
|
876
|
+
node: current,
|
|
877
|
+
messageId: "explicitSubscriptionAdd"
|
|
878
|
+
});
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
if (isSubscriptionUnsubscribeCall(context, current, subscriptionReferences)) {
|
|
882
|
+
context.report({
|
|
883
|
+
node: current,
|
|
884
|
+
messageId: "explicitUnsubscribe"
|
|
885
|
+
});
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
if (isUnmanagedSubscribeCall(context, current, rxjsSubscribableReferences, rxjsSubscribableLocalNames, rxjsNamespaces, takeUntilDestroyedLocalNames, interopNamespaces)) {
|
|
889
|
+
context.report({
|
|
890
|
+
node: current,
|
|
891
|
+
messageId: "explicitSubscribe"
|
|
892
|
+
});
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
//#endregion
|
|
209
901
|
//#region src/rules/avoid-ng-modules/index.ts
|
|
210
902
|
const DEFAULT_ALLOW_FOR_GROUPING = true;
|
|
211
903
|
const DEFAULT_ALLOW_FOR_PROVIDING = false;
|
|
212
904
|
const DEFAULT_ALLOW_FOR_ROUTING = false;
|
|
213
|
-
function
|
|
214
|
-
if (node.type !== "Decorator") return
|
|
215
|
-
|
|
905
|
+
function isNgModuleDecorator(context, node, importBindings, namespaceImportBindings) {
|
|
906
|
+
if (node.type !== "Decorator") return false;
|
|
907
|
+
const expression = node.expression ?? node;
|
|
908
|
+
const callee = expression?.type === "CallExpression" ? expression.callee : expression;
|
|
909
|
+
if (callee?.type === "Identifier") {
|
|
910
|
+
const binding = importBindings.get(callee.name);
|
|
911
|
+
return !!binding && binding.source === "@angular/core" && binding.importedName === "NgModule" && !isShadowedIdentifier(context, callee);
|
|
912
|
+
}
|
|
913
|
+
return callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && namespaceImportBindings.get(callee.object.name) === "@angular/core" && !isShadowedIdentifier(context, callee.object) && getPropertyName(callee.property) === "NgModule";
|
|
914
|
+
}
|
|
915
|
+
function getNgModuleMetadata(context, node, importBindings, namespaceImportBindings) {
|
|
916
|
+
if (!isNgModuleDecorator(context, node, importBindings, namespaceImportBindings)) return null;
|
|
216
917
|
const expression = node.expression;
|
|
217
918
|
if (expression?.type !== "CallExpression") return null;
|
|
218
919
|
const metadata = expression.arguments?.[0];
|
|
@@ -226,8 +927,19 @@ function getArrayElementsFromProperty(metadata, propertyName) {
|
|
|
226
927
|
function isMemberCall(node, methodName) {
|
|
227
928
|
return node.type === "CallExpression" && getPropertyName(node.callee?.property) === methodName;
|
|
228
929
|
}
|
|
229
|
-
function
|
|
230
|
-
|
|
930
|
+
function isModuleCallFromImport(node, expectedSource, expectedModuleName, importBindings, namespaceImportBindings) {
|
|
931
|
+
if (node.type !== "CallExpression") return false;
|
|
932
|
+
const callee = node.callee;
|
|
933
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
934
|
+
const moduleRef = callee.object;
|
|
935
|
+
if (moduleRef?.type === "Identifier") {
|
|
936
|
+
const binding = importBindings.get(moduleRef.name);
|
|
937
|
+
return !!binding && binding.source === expectedSource && binding.importedName === expectedModuleName;
|
|
938
|
+
}
|
|
939
|
+
if (moduleRef?.type !== "MemberExpression") return false;
|
|
940
|
+
if (moduleRef.object?.type !== "Identifier") return false;
|
|
941
|
+
if (namespaceImportBindings.get(moduleRef.object.name) !== expectedSource) return false;
|
|
942
|
+
return getPropertyName(moduleRef.property) === expectedModuleName;
|
|
231
943
|
}
|
|
232
944
|
function getNgModuleClassNode(decoratorNode) {
|
|
233
945
|
const parent = decoratorNode.parent;
|
|
@@ -264,48 +976,342 @@ const avoidNgModules = defineRule({
|
|
|
264
976
|
}
|
|
265
977
|
}],
|
|
266
978
|
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
|
-
|
|
979
|
+
avoidModuleImportsExports: "NgModule imports/exports are legacy composition. Prefer using standalone things directly.",
|
|
980
|
+
avoidForRoot: "Avoid module.forRoot(...). Prefer provideX(...) functions to register injection providers.",
|
|
981
|
+
avoidRouterForRoot: "Avoid RouterModule.forRoot(routes). Prefer provideRouter(routes). See https://angular.dev/guide/routing/define-routes",
|
|
982
|
+
avoidRouterForChild: "Avoid RouterModule.forChild(routes). Prefer provideRouter(...) and lazy route entries such as loadComponent: () => import('./components/auth/login-page').",
|
|
983
|
+
avoidNgrxStoreForRoot: "Avoid StoreModule.forRoot(...). Prefer provideStore(...) with standalone providers. See https://ngrx.io/guide/store",
|
|
984
|
+
avoidNgrxEffectsForRoot: "Avoid EffectsModule.forRoot(...). Prefer provideEffects(...) with standalone providers. See https://ngrx.io/guide/effects",
|
|
985
|
+
avoidNgrxStoreForFeature: "Avoid StoreModule.forFeature(...). Prefer provideState(...) with standalone providers. See https://ngrx.io/guide/store",
|
|
986
|
+
avoidNgrxEffectsForFeature: "Avoid EffectsModule.forFeature(...). Prefer provideEffects(...) scoped to feature providers. See https://ngrx.io/guide/effects",
|
|
987
|
+
avoidNgrxStoreDevtoolsInstrument: "Avoid StoreDevtoolsModule.instrument(...). Prefer provideStoreDevtools(...) with standalone providers. See https://ngrx.io/guide/store-devtools",
|
|
988
|
+
avoidNgrxRouterStoreForRoot: "Avoid StoreRouterConnectingModule.forRoot(...). Prefer provideRouterStore(...) with standalone providers. See https://ngrx.io/guide/router-store"
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
createOnce(context) {
|
|
992
|
+
const importBindings = /* @__PURE__ */ new Map();
|
|
993
|
+
const namespaceImportBindings = /* @__PURE__ */ new Map();
|
|
994
|
+
return {
|
|
995
|
+
before() {
|
|
996
|
+
importBindings.clear();
|
|
997
|
+
namespaceImportBindings.clear();
|
|
998
|
+
},
|
|
999
|
+
ImportDeclaration(node) {
|
|
1000
|
+
const source = node.source?.value;
|
|
1001
|
+
if (typeof source !== "string") return;
|
|
1002
|
+
for (const specifier of node.specifiers ?? []) {
|
|
1003
|
+
if (specifier.type === "ImportSpecifier") {
|
|
1004
|
+
const importedName = getPropertyName(specifier.imported);
|
|
1005
|
+
if (!importedName) continue;
|
|
1006
|
+
importBindings.set(specifier.local.name, {
|
|
1007
|
+
source,
|
|
1008
|
+
importedName
|
|
1009
|
+
});
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (specifier.type === "ImportNamespaceSpecifier") namespaceImportBindings.set(specifier.local.name, source);
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
Decorator(node) {
|
|
1016
|
+
const options = context.options[0] ?? {};
|
|
1017
|
+
const allowForGrouping = options.allowForGrouping ?? DEFAULT_ALLOW_FOR_GROUPING;
|
|
1018
|
+
const allowForProviding = options.allowForProviding ?? DEFAULT_ALLOW_FOR_PROVIDING;
|
|
1019
|
+
const allowForRouting = options.allowForRouting ?? DEFAULT_ALLOW_FOR_ROUTING;
|
|
1020
|
+
const metadata = getNgModuleMetadata(context, node, importBindings, namespaceImportBindings);
|
|
1021
|
+
if (!metadata) return;
|
|
1022
|
+
const importsElements = getArrayElementsFromProperty(metadata, "imports");
|
|
1023
|
+
const exportsElements = getArrayElementsFromProperty(metadata, "exports");
|
|
1024
|
+
if (!allowForGrouping && (importsElements.length > 0 || exportsElements.length > 0)) context.report({
|
|
1025
|
+
node,
|
|
1026
|
+
messageId: "avoidModuleImportsExports"
|
|
1027
|
+
});
|
|
1028
|
+
const importCalls = importsElements.filter((element) => element.type === "CallExpression");
|
|
1029
|
+
if (!allowForProviding) {
|
|
1030
|
+
for (const call of importCalls) {
|
|
1031
|
+
const methodName = getPropertyName(call.callee?.property);
|
|
1032
|
+
if (methodName === "forRoot" && isModuleCallFromImport(call, "@angular/router", "RouterModule", importBindings, namespaceImportBindings) && !allowForRouting) {
|
|
1033
|
+
context.report({
|
|
1034
|
+
node: call,
|
|
1035
|
+
messageId: "avoidRouterForRoot"
|
|
1036
|
+
});
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (methodName === "forRoot" && isModuleCallFromImport(call, "@ngrx/store", "StoreModule", importBindings, namespaceImportBindings)) {
|
|
1040
|
+
context.report({
|
|
1041
|
+
node: call,
|
|
1042
|
+
messageId: "avoidNgrxStoreForRoot"
|
|
1043
|
+
});
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (methodName === "forRoot" && isModuleCallFromImport(call, "@ngrx/effects", "EffectsModule", importBindings, namespaceImportBindings)) {
|
|
1047
|
+
context.report({
|
|
1048
|
+
node: call,
|
|
1049
|
+
messageId: "avoidNgrxEffectsForRoot"
|
|
1050
|
+
});
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (methodName === "forFeature" && isModuleCallFromImport(call, "@ngrx/store", "StoreModule", importBindings, namespaceImportBindings)) {
|
|
1054
|
+
context.report({
|
|
1055
|
+
node: call,
|
|
1056
|
+
messageId: "avoidNgrxStoreForFeature"
|
|
1057
|
+
});
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (methodName === "forFeature" && isModuleCallFromImport(call, "@ngrx/effects", "EffectsModule", importBindings, namespaceImportBindings)) {
|
|
1061
|
+
context.report({
|
|
1062
|
+
node: call,
|
|
1063
|
+
messageId: "avoidNgrxEffectsForFeature"
|
|
1064
|
+
});
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (methodName === "instrument" && isModuleCallFromImport(call, "@ngrx/store-devtools", "StoreDevtoolsModule", importBindings, namespaceImportBindings)) {
|
|
1068
|
+
context.report({
|
|
1069
|
+
node: call,
|
|
1070
|
+
messageId: "avoidNgrxStoreDevtoolsInstrument"
|
|
1071
|
+
});
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (methodName === "forRoot" && isModuleCallFromImport(call, "@ngrx/router-store", "StoreRouterConnectingModule", importBindings, namespaceImportBindings)) {
|
|
1075
|
+
context.report({
|
|
1076
|
+
node: call,
|
|
1077
|
+
messageId: "avoidNgrxRouterStoreForRoot"
|
|
1078
|
+
});
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (methodName === "forRoot" && isModuleCallFromImport(call, "@angular/router", "RouterModule", importBindings, namespaceImportBindings) && allowForRouting) continue;
|
|
1082
|
+
if (!isMemberCall(call, "forRoot")) continue;
|
|
1083
|
+
context.report({
|
|
1084
|
+
node: call,
|
|
1085
|
+
messageId: "avoidForRoot"
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
const staticForRootMethod = getStaticForRootMethod(getNgModuleClassNode(node));
|
|
1089
|
+
if (staticForRootMethod) context.report({
|
|
1090
|
+
node: staticForRootMethod.key ?? staticForRootMethod,
|
|
1091
|
+
messageId: "avoidForRoot"
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (!allowForRouting) for (const call of importCalls) {
|
|
1095
|
+
if (!isMemberCall(call, "forChild")) continue;
|
|
1096
|
+
if (!isModuleCallFromImport(call, "@angular/router", "RouterModule", importBindings, namespaceImportBindings)) continue;
|
|
1097
|
+
context.report({
|
|
1098
|
+
node: call,
|
|
1099
|
+
messageId: "avoidRouterForChild"
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/rules/avoid-rxjs-state-in-component/index.ts
|
|
1108
|
+
const TARGET_DECORATORS$1 = new Set(["Component", "Directive"]);
|
|
1109
|
+
const SUBJECT_NAMES = new Set([
|
|
1110
|
+
"BehaviorSubject",
|
|
1111
|
+
"ReplaySubject",
|
|
1112
|
+
"Subject"
|
|
1113
|
+
]);
|
|
1114
|
+
const FIELD_NODE_TYPES$1 = new Set([
|
|
1115
|
+
"AccessorProperty",
|
|
1116
|
+
"FieldDefinition",
|
|
1117
|
+
"PropertyDefinition"
|
|
1118
|
+
]);
|
|
1119
|
+
function hasTargetDecorator$1(context, classNode, decoratorImports) {
|
|
1120
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
1121
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, decoratorImports));
|
|
1122
|
+
}
|
|
1123
|
+
function getImportedSubjectKind(localName, subjectLocalNames) {
|
|
1124
|
+
return subjectLocalNames.get(localName) ?? null;
|
|
1125
|
+
}
|
|
1126
|
+
function getSubjectKindFromType(context, node, subjectLocalNames, rxjsNamespaces) {
|
|
1127
|
+
if (!node) return null;
|
|
1128
|
+
if (node.type !== "TSTypeReference") return null;
|
|
1129
|
+
const typeName = getTypeName(node.typeName);
|
|
1130
|
+
if (!typeName) return null;
|
|
1131
|
+
const localKind = getImportedSubjectKind(typeName, subjectLocalNames);
|
|
1132
|
+
if (localKind && node.typeName?.type === "Identifier" && !isShadowedIdentifier(context, node.typeName)) return localKind;
|
|
1133
|
+
if (!typeName.includes(".")) return null;
|
|
1134
|
+
const [namespaceName, memberName] = typeName.split(".");
|
|
1135
|
+
if (!rxjsNamespaces.has(namespaceName)) return null;
|
|
1136
|
+
if (node.typeName?.type === "TSQualifiedName" && node.typeName.left?.type === "Identifier" && isShadowedIdentifier(context, node.typeName.left)) return null;
|
|
1137
|
+
return SUBJECT_NAMES.has(memberName) ? memberName : null;
|
|
1138
|
+
}
|
|
1139
|
+
function getSubjectKindFromConstructor(context, node, subjectLocalNames, rxjsNamespaces) {
|
|
1140
|
+
const expression = unwrapExpression(node);
|
|
1141
|
+
if (expression?.type !== "NewExpression") return null;
|
|
1142
|
+
const callee = unwrapExpression(expression.callee);
|
|
1143
|
+
if (callee?.type === "Identifier") {
|
|
1144
|
+
if (isShadowedIdentifier(context, callee)) return null;
|
|
1145
|
+
return getImportedSubjectKind(callee.name, subjectLocalNames);
|
|
1146
|
+
}
|
|
1147
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
1148
|
+
if (callee.object?.type !== "Identifier" || !rxjsNamespaces.has(callee.object.name)) return null;
|
|
1149
|
+
if (isShadowedIdentifier(context, callee.object)) return null;
|
|
1150
|
+
const memberName = getPropertyName(callee.property);
|
|
1151
|
+
return SUBJECT_NAMES.has(memberName) ? memberName : null;
|
|
1152
|
+
}
|
|
1153
|
+
function getMemberName(node) {
|
|
1154
|
+
const expression = unwrapExpression(node);
|
|
1155
|
+
if (!expression) return null;
|
|
1156
|
+
if (expression.type === "Identifier") return expression.name;
|
|
1157
|
+
if (expression.type === "PrivateIdentifier") return expression.name;
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
function getThisFieldName(node) {
|
|
1161
|
+
const expression = unwrapExpression(node);
|
|
1162
|
+
if (expression?.type !== "MemberExpression") return null;
|
|
1163
|
+
if (expression.object?.type !== "ThisExpression") return null;
|
|
1164
|
+
return getPropertyName(expression.property);
|
|
1165
|
+
}
|
|
1166
|
+
function isCallOnThisField(node, fields) {
|
|
1167
|
+
const callNode = unwrapExpression(node);
|
|
1168
|
+
if (callNode?.type !== "CallExpression") return null;
|
|
1169
|
+
const callee = unwrapExpression(callNode.callee);
|
|
1170
|
+
if (callee?.type !== "MemberExpression") return null;
|
|
1171
|
+
const fieldName = getThisFieldName(callee.object);
|
|
1172
|
+
const methodName = getPropertyName(callee.property);
|
|
1173
|
+
if (!fieldName || !methodName || !fields.has(fieldName)) return null;
|
|
1174
|
+
return {
|
|
1175
|
+
fieldName,
|
|
1176
|
+
methodName,
|
|
1177
|
+
argumentCount: callNode.arguments?.length ?? 0
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
function isNgOnDestroyMethod(member) {
|
|
1181
|
+
return member.type === "MethodDefinition" && getPropertyName(member.key) === "ngOnDestroy" && !!member.value;
|
|
1182
|
+
}
|
|
1183
|
+
function walkNode(node, visitor) {
|
|
1184
|
+
if (!node) return;
|
|
1185
|
+
if (Array.isArray(node)) {
|
|
1186
|
+
for (const child of node) walkNode(child, visitor);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
visitor(node);
|
|
1190
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1191
|
+
if (key === "parent") continue;
|
|
1192
|
+
if (!value || typeof value !== "object") continue;
|
|
1193
|
+
walkNode(value, visitor);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function getUsage(usages, fieldName) {
|
|
1197
|
+
const existing = usages.get(fieldName);
|
|
1198
|
+
if (existing) return existing;
|
|
1199
|
+
const next = {
|
|
1200
|
+
hasAsObservable: false,
|
|
1201
|
+
hasNextWithValue: false,
|
|
1202
|
+
hasNgOnDestroyComplete: false,
|
|
1203
|
+
hasNgOnDestroyNext: false
|
|
1204
|
+
};
|
|
1205
|
+
usages.set(fieldName, next);
|
|
1206
|
+
return next;
|
|
1207
|
+
}
|
|
1208
|
+
function collectSubjectFields(context, classBody, subjectLocalNames, rxjsNamespaces) {
|
|
1209
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1210
|
+
for (const member of classBody.body ?? []) {
|
|
1211
|
+
if (!FIELD_NODE_TYPES$1.has(member.type)) continue;
|
|
1212
|
+
const fieldName = getMemberName(member.key);
|
|
1213
|
+
if (!fieldName) continue;
|
|
1214
|
+
const typeNode = member.typeAnnotation?.typeAnnotation;
|
|
1215
|
+
const kind = getSubjectKindFromConstructor(context, member.value, subjectLocalNames, rxjsNamespaces) ?? getSubjectKindFromType(context, typeNode, subjectLocalNames, rxjsNamespaces);
|
|
1216
|
+
if (!kind) continue;
|
|
1217
|
+
fields.set(fieldName, {
|
|
1218
|
+
key: member.key ?? member,
|
|
1219
|
+
kind
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
return fields;
|
|
1223
|
+
}
|
|
1224
|
+
function collectFieldUsages(classBody, fields) {
|
|
1225
|
+
const usages = /* @__PURE__ */ new Map();
|
|
1226
|
+
const ngOnDestroyMethods = /* @__PURE__ */ new Set();
|
|
1227
|
+
for (const member of classBody.body ?? []) if (isNgOnDestroyMethod(member)) ngOnDestroyMethods.add(member.value);
|
|
1228
|
+
walkNode(classBody, (node) => {
|
|
1229
|
+
const call = isCallOnThisField(node, fields);
|
|
1230
|
+
if (!call) return;
|
|
1231
|
+
const usage = getUsage(usages, call.fieldName);
|
|
1232
|
+
if (call.methodName === "asObservable") usage.hasAsObservable = true;
|
|
1233
|
+
if (call.methodName === "next" && call.argumentCount > 0) usage.hasNextWithValue = true;
|
|
1234
|
+
const isInsideNgOnDestroy = ngOnDestroyMethods.has(node.parent) || ngOnDestroyMethods.has(node);
|
|
1235
|
+
let current = node.parent;
|
|
1236
|
+
while (!isInsideNgOnDestroy && current) {
|
|
1237
|
+
if (ngOnDestroyMethods.has(current)) break;
|
|
1238
|
+
current = current.parent;
|
|
1239
|
+
}
|
|
1240
|
+
if (!current && !ngOnDestroyMethods.has(node.parent) && !ngOnDestroyMethods.has(node)) return;
|
|
1241
|
+
if (call.methodName === "next") usage.hasNgOnDestroyNext = true;
|
|
1242
|
+
if (call.methodName === "complete") usage.hasNgOnDestroyComplete = true;
|
|
1243
|
+
});
|
|
1244
|
+
return usages;
|
|
1245
|
+
}
|
|
1246
|
+
function isDestroySubjectUsage(usage) {
|
|
1247
|
+
return !!usage?.hasNgOnDestroyNext && !!usage.hasNgOnDestroyComplete;
|
|
1248
|
+
}
|
|
1249
|
+
const avoidRxjsStateInComponent = defineRule({
|
|
1250
|
+
meta: {
|
|
1251
|
+
type: "suggestion",
|
|
1252
|
+
docs: {
|
|
1253
|
+
description: "Avoid using RxJS Subject classes for component or directive state and destruction lifecycle.",
|
|
1254
|
+
recommended: true
|
|
1255
|
+
},
|
|
1256
|
+
schema: [],
|
|
1257
|
+
messages: {
|
|
1258
|
+
avoidRxjsState: "Avoid using RxJS {{kind}} for component/directive state. Prefer signal(), computed(), or linkedSignal().",
|
|
1259
|
+
avoidDestroySubject: "Avoid destroy Subject lifecycle management. Prefer takeUntilDestroyed() from @angular/core/rxjs-interop."
|
|
270
1260
|
}
|
|
271
1261
|
},
|
|
272
1262
|
createOnce(context) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
1263
|
+
const subjectLocalNames = /* @__PURE__ */ new Map();
|
|
1264
|
+
const rxjsNamespaces = /* @__PURE__ */ new Set();
|
|
1265
|
+
const decoratorImports = {
|
|
1266
|
+
decoratorNames: TARGET_DECORATORS$1,
|
|
1267
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
1268
|
+
angularNamespaces: /* @__PURE__ */ new Set()
|
|
1269
|
+
};
|
|
1270
|
+
return {
|
|
1271
|
+
before() {
|
|
1272
|
+
subjectLocalNames.clear();
|
|
1273
|
+
rxjsNamespaces.clear();
|
|
1274
|
+
decoratorImports.decoratorLocalNames.clear();
|
|
1275
|
+
decoratorImports.angularNamespaces.clear();
|
|
1276
|
+
},
|
|
1277
|
+
ImportDeclaration(node) {
|
|
1278
|
+
if (node.source?.value === "@angular/core") {
|
|
1279
|
+
for (const specifier of node.specifiers ?? []) addAngularCoreDecoratorImport(specifier, TARGET_DECORATORS$1, decoratorImports);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (node.source?.value !== "rxjs") return;
|
|
1283
|
+
for (const specifier of node.specifiers ?? []) {
|
|
1284
|
+
if (specifier.type === "ImportSpecifier") {
|
|
1285
|
+
const importedName = getPropertyName(specifier.imported);
|
|
1286
|
+
if (SUBJECT_NAMES.has(importedName)) subjectLocalNames.set(specifier.local.name, importedName);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
if (specifier.type === "ImportNamespaceSpecifier") rxjsNamespaces.add(specifier.local.name);
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
ClassBody(node) {
|
|
1293
|
+
const classBody = node;
|
|
1294
|
+
const classNode = classBody.parent;
|
|
1295
|
+
if (!hasTargetDecorator$1(context, classNode, decoratorImports)) return;
|
|
1296
|
+
const fields = collectSubjectFields(context, classBody, subjectLocalNames, rxjsNamespaces);
|
|
1297
|
+
const usages = collectFieldUsages(classBody, fields);
|
|
1298
|
+
for (const [fieldName, field] of fields) {
|
|
1299
|
+
const usage = usages.get(fieldName);
|
|
1300
|
+
if (field.kind === "Subject" && isDestroySubjectUsage(usage)) {
|
|
1301
|
+
context.report({
|
|
1302
|
+
node: field.key,
|
|
1303
|
+
messageId: "avoidDestroySubject"
|
|
1304
|
+
});
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
290
1307
|
context.report({
|
|
291
|
-
node:
|
|
292
|
-
messageId: "
|
|
1308
|
+
node: field.key,
|
|
1309
|
+
messageId: "avoidRxjsState",
|
|
1310
|
+
data: { kind: field.kind }
|
|
293
1311
|
});
|
|
294
1312
|
}
|
|
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
|
-
context.report({
|
|
304
|
-
node: call,
|
|
305
|
-
messageId: "avoidRouterForChild"
|
|
306
|
-
});
|
|
307
1313
|
}
|
|
308
|
-
}
|
|
1314
|
+
};
|
|
309
1315
|
}
|
|
310
1316
|
});
|
|
311
1317
|
//#endregion
|
|
@@ -323,38 +1329,49 @@ const KNOWN_SIGNAL_CREATION_FUNCTIONS = new Set([
|
|
|
323
1329
|
const LINKED_SIGNAL_CREATOR_NAME = "linkedSignal";
|
|
324
1330
|
const COMPUTED_CREATOR_NAME = "computed";
|
|
325
1331
|
const EFFECT_CREATOR_NAME = "effect";
|
|
326
|
-
function isAngularCoreNamespaceMember(node, angularNamespaces, memberName) {
|
|
327
|
-
return node?.type === "MemberExpression" && node.object?.type === "Identifier" && angularNamespaces.has(node.object.name) && getPropertyName(node.property) === memberName;
|
|
1332
|
+
function isAngularCoreNamespaceMember(context, node, angularNamespaces, memberName) {
|
|
1333
|
+
return node?.type === "MemberExpression" && node.object?.type === "Identifier" && angularNamespaces.has(node.object.name) && !isShadowedIdentifier(context, node.object) && getPropertyName(node.property) === memberName;
|
|
328
1334
|
}
|
|
329
|
-
function isSignalCreatorCall(node, signalCreatorNames, angularNamespaces) {
|
|
1335
|
+
function isSignalCreatorCall(context, node, signalCreatorNames, angularNamespaces) {
|
|
330
1336
|
if (node?.type !== "CallExpression") return false;
|
|
331
1337
|
const callee = node.callee;
|
|
332
|
-
return callee?.type === "Identifier" && signalCreatorNames.has(callee.name) || [...KNOWN_SIGNAL_CREATION_FUNCTIONS].some((name) => isAngularCoreNamespaceMember(callee, angularNamespaces, name));
|
|
1338
|
+
return callee?.type === "Identifier" && signalCreatorNames.has(callee.name) && !isShadowedIdentifier(context, callee) || [...KNOWN_SIGNAL_CREATION_FUNCTIONS].some((name) => isAngularCoreNamespaceMember(context, callee, angularNamespaces, name));
|
|
333
1339
|
}
|
|
334
|
-
function isEffectCall(node, effectNames, angularNamespaces) {
|
|
1340
|
+
function isEffectCall(context, node, effectNames, angularNamespaces) {
|
|
335
1341
|
const callee = node.callee;
|
|
336
|
-
return callee?.type === "Identifier" && effectNames.has(callee.name) || isAngularCoreNamespaceMember(callee, angularNamespaces, "effect");
|
|
1342
|
+
return callee?.type === "Identifier" && effectNames.has(callee.name) && !isShadowedIdentifier(context, callee) || isAngularCoreNamespaceMember(context, callee, angularNamespaces, "effect");
|
|
337
1343
|
}
|
|
338
|
-
function isReactiveCreatorCall(node, creatorNames, angularNamespaces, angularMemberName) {
|
|
1344
|
+
function isReactiveCreatorCall(context, node, creatorNames, angularNamespaces, angularMemberName) {
|
|
339
1345
|
const callee = node.callee;
|
|
340
|
-
return callee?.type === "Identifier" && creatorNames.has(callee.name) || isAngularCoreNamespaceMember(callee, angularNamespaces, angularMemberName);
|
|
1346
|
+
return callee?.type === "Identifier" && creatorNames.has(callee.name) && !isShadowedIdentifier(context, callee) || isAngularCoreNamespaceMember(context, callee, angularNamespaces, angularMemberName);
|
|
341
1347
|
}
|
|
342
|
-
function isKnownSignalObject(objectNode,
|
|
1348
|
+
function isKnownSignalObject(context, objectNode, signalVariableBindings, classSignalProperties) {
|
|
343
1349
|
if (!objectNode) return false;
|
|
344
|
-
if (objectNode.type === "Identifier")
|
|
1350
|
+
if (objectNode.type === "Identifier") {
|
|
1351
|
+
const trackedBindings = signalVariableBindings.get(objectNode.name);
|
|
1352
|
+
if (!trackedBindings) return false;
|
|
1353
|
+
const nearestBinding = findNearestBindingIdentifier(context, objectNode);
|
|
1354
|
+
return !!nearestBinding && trackedBindings.has(nearestBinding);
|
|
1355
|
+
}
|
|
345
1356
|
return objectNode.type === "MemberExpression" && objectNode.object?.type === "ThisExpression" && objectNode.property?.type === "Identifier" && classSignalProperties.has(objectNode.property.name);
|
|
346
1357
|
}
|
|
347
|
-
|
|
1358
|
+
const FUNCTION_NODE_TYPES = new Set([
|
|
1359
|
+
"ArrowFunctionExpression",
|
|
1360
|
+
"FunctionExpression",
|
|
1361
|
+
"FunctionDeclaration"
|
|
1362
|
+
]);
|
|
1363
|
+
function visitNodes(node, visitor, skipFunctionBodies = false) {
|
|
348
1364
|
if (!node) return;
|
|
349
1365
|
if (Array.isArray(node)) {
|
|
350
|
-
for (const item of node) visitNodes
|
|
1366
|
+
for (const item of node) visitNodes(item, visitor, skipFunctionBodies);
|
|
351
1367
|
return;
|
|
352
1368
|
}
|
|
1369
|
+
if (skipFunctionBodies && FUNCTION_NODE_TYPES.has(node.type)) return;
|
|
353
1370
|
visitor(node);
|
|
354
1371
|
for (const [key, value] of Object.entries(node)) {
|
|
355
1372
|
if (key === "parent") continue;
|
|
356
1373
|
if (!value || typeof value !== "object") continue;
|
|
357
|
-
visitNodes
|
|
1374
|
+
visitNodes(value, visitor, skipFunctionBodies);
|
|
358
1375
|
}
|
|
359
1376
|
}
|
|
360
1377
|
const avoidWritingSignalsInReactiveContext = defineRule({
|
|
@@ -386,7 +1403,7 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
386
1403
|
const linkedSignalNames = /* @__PURE__ */ new Set();
|
|
387
1404
|
const signalCreatorNames = /* @__PURE__ */ new Set();
|
|
388
1405
|
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
389
|
-
const
|
|
1406
|
+
const signalVariableBindings = /* @__PURE__ */ new Map();
|
|
390
1407
|
const classSignalProperties = /* @__PURE__ */ new Set();
|
|
391
1408
|
return {
|
|
392
1409
|
before() {
|
|
@@ -395,7 +1412,7 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
395
1412
|
linkedSignalNames.clear();
|
|
396
1413
|
signalCreatorNames.clear();
|
|
397
1414
|
angularNamespaces.clear();
|
|
398
|
-
|
|
1415
|
+
signalVariableBindings.clear();
|
|
399
1416
|
classSignalProperties.clear();
|
|
400
1417
|
},
|
|
401
1418
|
ImportDeclaration(node) {
|
|
@@ -414,13 +1431,15 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
414
1431
|
VariableDeclarator(node) {
|
|
415
1432
|
const declarator = node;
|
|
416
1433
|
if (declarator.id?.type !== "Identifier") return;
|
|
417
|
-
if (!isSignalCreatorCall(declarator.init, signalCreatorNames, angularNamespaces)) return;
|
|
418
|
-
|
|
1434
|
+
if (!isSignalCreatorCall(context, declarator.init, signalCreatorNames, angularNamespaces)) return;
|
|
1435
|
+
const bindings = signalVariableBindings.get(declarator.id.name) ?? /* @__PURE__ */ new Set();
|
|
1436
|
+
bindings.add(declarator.id);
|
|
1437
|
+
signalVariableBindings.set(declarator.id.name, bindings);
|
|
419
1438
|
},
|
|
420
1439
|
"PropertyDefinition, FieldDefinition, AccessorProperty"(node) {
|
|
421
1440
|
const property = node;
|
|
422
1441
|
if (property.key?.type !== "Identifier") return;
|
|
423
|
-
if (!isSignalCreatorCall(property.value, signalCreatorNames, angularNamespaces)) return;
|
|
1442
|
+
if (!isSignalCreatorCall(context, property.value, signalCreatorNames, angularNamespaces)) return;
|
|
424
1443
|
classSignalProperties.add(property.key.name);
|
|
425
1444
|
},
|
|
426
1445
|
CallExpression(node) {
|
|
@@ -429,21 +1448,21 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
429
1448
|
const allowComputedAndLinkedSignals = options.allowComputedAndLinkedSignals ?? false;
|
|
430
1449
|
const callNode = node;
|
|
431
1450
|
const callbackCandidates = [];
|
|
432
|
-
if (!allowEffects && isEffectCall(callNode, effectNames, angularNamespaces)) {
|
|
1451
|
+
if (!allowEffects && isEffectCall(context, callNode, effectNames, angularNamespaces)) {
|
|
433
1452
|
const callback = callNode.arguments?.[0];
|
|
434
1453
|
if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") callbackCandidates.push({
|
|
435
1454
|
callback,
|
|
436
1455
|
contextName: "effect()"
|
|
437
1456
|
});
|
|
438
1457
|
}
|
|
439
|
-
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(callNode, computedNames, angularNamespaces, COMPUTED_CREATOR_NAME)) {
|
|
1458
|
+
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(context, callNode, computedNames, angularNamespaces, COMPUTED_CREATOR_NAME)) {
|
|
440
1459
|
const callback = callNode.arguments?.[0];
|
|
441
1460
|
if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") callbackCandidates.push({
|
|
442
1461
|
callback,
|
|
443
1462
|
contextName: "computed()"
|
|
444
1463
|
});
|
|
445
1464
|
}
|
|
446
|
-
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(callNode, linkedSignalNames, angularNamespaces, LINKED_SIGNAL_CREATOR_NAME)) for (const argumentNode of callNode.arguments ?? []) {
|
|
1465
|
+
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(context, callNode, linkedSignalNames, angularNamespaces, LINKED_SIGNAL_CREATOR_NAME)) for (const argumentNode of callNode.arguments ?? []) {
|
|
447
1466
|
const argument = argumentNode;
|
|
448
1467
|
if (argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression") {
|
|
449
1468
|
callbackCandidates.push({
|
|
@@ -464,27 +1483,27 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
464
1483
|
});
|
|
465
1484
|
}
|
|
466
1485
|
}
|
|
467
|
-
for (const { callback, contextName } of callbackCandidates) visitNodes
|
|
1486
|
+
for (const { callback, contextName } of callbackCandidates) visitNodes(callback.body, (current) => {
|
|
468
1487
|
if (current.type !== "CallExpression") return;
|
|
469
1488
|
const callee = current.callee;
|
|
470
1489
|
if (callee?.type !== "MemberExpression") return;
|
|
471
1490
|
const methodName = getPropertyName(callee.property);
|
|
472
1491
|
if (!methodName || !SIGNAL_WRITE_METHODS.has(methodName)) return;
|
|
473
|
-
if (!isKnownSignalObject(callee.object,
|
|
1492
|
+
if (!isKnownSignalObject(context, callee.object, signalVariableBindings, classSignalProperties)) return;
|
|
474
1493
|
context.report({
|
|
475
1494
|
node: callee.property ?? callee,
|
|
476
1495
|
messageId: "avoidSignalWriteInReactiveContext",
|
|
477
1496
|
data: { contextName }
|
|
478
1497
|
});
|
|
479
|
-
});
|
|
1498
|
+
}, true);
|
|
480
1499
|
},
|
|
481
|
-
|
|
1500
|
+
after() {
|
|
482
1501
|
effectNames.clear();
|
|
483
1502
|
computedNames.clear();
|
|
484
1503
|
linkedSignalNames.clear();
|
|
485
1504
|
signalCreatorNames.clear();
|
|
486
1505
|
angularNamespaces.clear();
|
|
487
|
-
|
|
1506
|
+
signalVariableBindings.clear();
|
|
488
1507
|
classSignalProperties.clear();
|
|
489
1508
|
}
|
|
490
1509
|
};
|
|
@@ -492,6 +1511,7 @@ const avoidWritingSignalsInReactiveContext = defineRule({
|
|
|
492
1511
|
});
|
|
493
1512
|
//#endregion
|
|
494
1513
|
//#region src/rules/component-class-matches-filename/index.ts
|
|
1514
|
+
const COMPONENT_DECORATORS$1 = new Set(["Component"]);
|
|
495
1515
|
function toPascalCase(raw) {
|
|
496
1516
|
return raw.split(/[-_\s.]+/u).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
497
1517
|
}
|
|
@@ -514,20 +1534,33 @@ const componentClassMatchesFilename = defineRule({
|
|
|
514
1534
|
messages: { classNameMismatch: "Component class name should be '{{expectedName}}' to match the filename '{{filename}}'." }
|
|
515
1535
|
},
|
|
516
1536
|
createOnce(context) {
|
|
517
|
-
const
|
|
1537
|
+
const componentClasses = [];
|
|
1538
|
+
const decoratorImports = {
|
|
1539
|
+
decoratorNames: COMPONENT_DECORATORS$1,
|
|
1540
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
1541
|
+
angularNamespaces: /* @__PURE__ */ new Set()
|
|
1542
|
+
};
|
|
518
1543
|
return {
|
|
519
1544
|
before() {
|
|
520
|
-
|
|
1545
|
+
componentClasses.length = 0;
|
|
1546
|
+
decoratorImports.decoratorLocalNames.clear();
|
|
1547
|
+
decoratorImports.angularNamespaces.clear();
|
|
1548
|
+
},
|
|
1549
|
+
ImportDeclaration(node) {
|
|
1550
|
+
if (node.source?.value !== "@angular/core") return;
|
|
1551
|
+
for (const specifier of node.specifiers ?? []) addAngularCoreDecoratorImport(specifier, COMPONENT_DECORATORS$1, decoratorImports);
|
|
521
1552
|
},
|
|
522
1553
|
ClassDeclaration(node) {
|
|
523
|
-
|
|
1554
|
+
const classNode = node;
|
|
1555
|
+
if (!Array.isArray(classNode.decorators)) return;
|
|
1556
|
+
if (classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, decoratorImports))) componentClasses.push(classNode);
|
|
524
1557
|
},
|
|
525
1558
|
after() {
|
|
526
1559
|
const filename = context.filename ?? "";
|
|
527
1560
|
const expectedName = getExpectedComponentClassName(filename);
|
|
528
1561
|
if (!expectedName) return;
|
|
529
1562
|
const baseFilename = filename.split(/[/\\]/u).at(-1) ?? filename;
|
|
530
|
-
const classNode =
|
|
1563
|
+
const classNode = componentClasses.find((candidate) => candidate.parent?.type === "ExportNamedDeclaration" || candidate.parent?.type === "ExportDefaultDeclaration") ?? null ?? componentClasses[0];
|
|
531
1564
|
if (!classNode?.id?.name) return;
|
|
532
1565
|
if (classNode.id.name === expectedName) return;
|
|
533
1566
|
context.report({
|
|
@@ -543,13 +1576,158 @@ const componentClassMatchesFilename = defineRule({
|
|
|
543
1576
|
}
|
|
544
1577
|
});
|
|
545
1578
|
//#endregion
|
|
1579
|
+
//#region src/rules/component-resource-filenames/index.ts
|
|
1580
|
+
const COMPONENT_DECORATORS = new Set(["Component"]);
|
|
1581
|
+
const STYLE_EXTENSIONS = new Set([
|
|
1582
|
+
"css",
|
|
1583
|
+
"less",
|
|
1584
|
+
"sass",
|
|
1585
|
+
"scss"
|
|
1586
|
+
]);
|
|
1587
|
+
function getExpectedStem(filename) {
|
|
1588
|
+
const base = filename.split(/[/\\]/u).at(-1) ?? "";
|
|
1589
|
+
if (!base.endsWith(".ts")) return null;
|
|
1590
|
+
return base.slice(0, -3);
|
|
1591
|
+
}
|
|
1592
|
+
function normalizeResourcePath(value) {
|
|
1593
|
+
return value.startsWith("./") ? value.slice(2) : value;
|
|
1594
|
+
}
|
|
1595
|
+
function getResourceBasename(value) {
|
|
1596
|
+
return normalizeResourcePath(value).split(/[/\\]/u).at(-1) ?? value;
|
|
1597
|
+
}
|
|
1598
|
+
function getExtension(value) {
|
|
1599
|
+
const basename = getResourceBasename(value);
|
|
1600
|
+
const index = basename.lastIndexOf(".");
|
|
1601
|
+
return index === -1 ? null : basename.slice(index + 1);
|
|
1602
|
+
}
|
|
1603
|
+
function getStaticString(node) {
|
|
1604
|
+
if (!node) return null;
|
|
1605
|
+
if (node.type === "Literal" || node.type === "StringLiteral") {
|
|
1606
|
+
if (typeof node.value !== "string") return null;
|
|
1607
|
+
return { value: node.value };
|
|
1608
|
+
}
|
|
1609
|
+
if (node.type === "TemplateLiteral" && node.expressions?.length === 0) return { value: node.quasis?.[0]?.value?.cooked ?? node.quasis?.[0]?.value?.raw ?? "" };
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
function getComponentMetadata(context, node, decoratorImports) {
|
|
1613
|
+
if (node.type !== "Decorator") return null;
|
|
1614
|
+
if (!isAngularCoreDecorator(context, node, decoratorImports)) return null;
|
|
1615
|
+
const expression = node.expression;
|
|
1616
|
+
if (expression?.type !== "CallExpression") return null;
|
|
1617
|
+
const metadata = expression.arguments?.[0];
|
|
1618
|
+
return metadata?.type === "ObjectExpression" ? metadata : null;
|
|
1619
|
+
}
|
|
1620
|
+
const componentResourceFilenames = defineRule({
|
|
1621
|
+
meta: {
|
|
1622
|
+
type: "suggestion",
|
|
1623
|
+
docs: {
|
|
1624
|
+
description: "Require Angular component templateUrl and styleUrl resource filenames to match the component TypeScript filename.",
|
|
1625
|
+
recommended: true
|
|
1626
|
+
},
|
|
1627
|
+
schema: [],
|
|
1628
|
+
messages: {
|
|
1629
|
+
templateUrlMismatch: "Component templateUrl should be '{{expected}}' to match this component filename.",
|
|
1630
|
+
styleUrlMismatch: "Component style URL should be '{{expected}}' to match this component filename.",
|
|
1631
|
+
unsupportedStyleExtension: "Component style URL should use one of: .css, .less, .sass, or .scss."
|
|
1632
|
+
}
|
|
1633
|
+
},
|
|
1634
|
+
createOnce(context) {
|
|
1635
|
+
const decoratorImports = {
|
|
1636
|
+
decoratorNames: COMPONENT_DECORATORS,
|
|
1637
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
1638
|
+
angularNamespaces: /* @__PURE__ */ new Set()
|
|
1639
|
+
};
|
|
1640
|
+
function reportTemplateUrl(valueNode) {
|
|
1641
|
+
const expectedStem = getExpectedStem(context.filename ?? "");
|
|
1642
|
+
if (!expectedStem) return;
|
|
1643
|
+
const staticString = getStaticString(valueNode);
|
|
1644
|
+
if (!staticString) return;
|
|
1645
|
+
const expected = `./${expectedStem}.html`;
|
|
1646
|
+
if (normalizeResourcePath(staticString.value) === `${expectedStem}.html`) return;
|
|
1647
|
+
context.report({
|
|
1648
|
+
node: valueNode,
|
|
1649
|
+
messageId: "templateUrlMismatch",
|
|
1650
|
+
data: { expected }
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
function reportStyleUrl(valueNode) {
|
|
1654
|
+
const expectedStem = getExpectedStem(context.filename ?? "");
|
|
1655
|
+
if (!expectedStem) return;
|
|
1656
|
+
const staticString = getStaticString(valueNode);
|
|
1657
|
+
if (!staticString) return;
|
|
1658
|
+
const extension = getExtension(staticString.value);
|
|
1659
|
+
if (!extension || !STYLE_EXTENSIONS.has(extension)) {
|
|
1660
|
+
context.report({
|
|
1661
|
+
node: valueNode,
|
|
1662
|
+
messageId: "unsupportedStyleExtension"
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const expected = `./${expectedStem}.${extension}`;
|
|
1667
|
+
if (normalizeResourcePath(staticString.value) === `${expectedStem}.${extension}`) return;
|
|
1668
|
+
context.report({
|
|
1669
|
+
node: valueNode,
|
|
1670
|
+
messageId: "styleUrlMismatch",
|
|
1671
|
+
data: { expected }
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
return {
|
|
1675
|
+
before() {
|
|
1676
|
+
decoratorImports.decoratorLocalNames.clear();
|
|
1677
|
+
decoratorImports.angularNamespaces.clear();
|
|
1678
|
+
},
|
|
1679
|
+
ImportDeclaration(node) {
|
|
1680
|
+
if (node.source?.value !== "@angular/core") return;
|
|
1681
|
+
for (const specifier of node.specifiers ?? []) addAngularCoreDecoratorImport(specifier, COMPONENT_DECORATORS, decoratorImports);
|
|
1682
|
+
},
|
|
1683
|
+
Decorator(node) {
|
|
1684
|
+
const metadata = getComponentMetadata(context, node, decoratorImports);
|
|
1685
|
+
if (!metadata) return;
|
|
1686
|
+
for (const property of metadata.properties ?? []) {
|
|
1687
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
1688
|
+
const propertyName = getPropertyName(property.key);
|
|
1689
|
+
if (propertyName === "templateUrl") {
|
|
1690
|
+
reportTemplateUrl(property.value);
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
if (propertyName === "styleUrl") {
|
|
1694
|
+
reportStyleUrl(property.value);
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
//#endregion
|
|
546
1703
|
//#region src/rules/prefer-load-component-over-load-children/index.ts
|
|
547
|
-
function
|
|
1704
|
+
function isImportedTypeName(typeNode, localNames, routerNamespaces) {
|
|
1705
|
+
const typeName = getTypeName(typeNode);
|
|
1706
|
+
if (!typeName) return false;
|
|
1707
|
+
if (!typeName.includes(".")) return localNames.has(typeName);
|
|
1708
|
+
const [namespaceName, memberName] = typeName.split(".");
|
|
1709
|
+
return routerNamespaces.has(namespaceName) && localNames.has(memberName);
|
|
1710
|
+
}
|
|
1711
|
+
function getTypeParameterNodes(typeNode) {
|
|
1712
|
+
return typeNode.typeParameters?.params ?? typeNode.typeArguments?.params ?? [];
|
|
1713
|
+
}
|
|
1714
|
+
function isRouteType(typeNode, imports) {
|
|
1715
|
+
return typeNode?.type === "TSTypeReference" && isImportedTypeName(typeNode.typeName, imports.routeTypeLocalNames, imports.routerNamespaces);
|
|
1716
|
+
}
|
|
1717
|
+
function isRouteArrayType(typeNode, imports) {
|
|
1718
|
+
if (!typeNode) return false;
|
|
1719
|
+
if (typeNode.type === "TSTypeReference" && isImportedTypeName(typeNode.typeName, imports.routesTypeLocalNames, imports.routerNamespaces)) return true;
|
|
1720
|
+
if (typeNode.type === "TSArrayType") return isRouteType(typeNode.elementType, imports);
|
|
1721
|
+
if (typeNode.type !== "TSTypeReference") return false;
|
|
1722
|
+
const typeName = getTypeName(typeNode.typeName);
|
|
1723
|
+
if (typeName !== "Array" && typeName !== "ReadonlyArray") return false;
|
|
1724
|
+
const [elementType] = getTypeParameterNodes(typeNode);
|
|
1725
|
+
return isRouteType(elementType, imports);
|
|
1726
|
+
}
|
|
1727
|
+
function isRoutesTypeAnnotation(node, imports) {
|
|
548
1728
|
const typeAnnotation = node?.typeAnnotation;
|
|
549
1729
|
if (!typeAnnotation || typeAnnotation.type !== "TSTypeAnnotation") return false;
|
|
550
|
-
|
|
551
|
-
if (!annotation || annotation.type !== "TSTypeReference") return false;
|
|
552
|
-
return getPropertyName(annotation.typeName) === "Routes";
|
|
1730
|
+
return isRouteArrayType(typeAnnotation.typeAnnotation, imports);
|
|
553
1731
|
}
|
|
554
1732
|
function isExportedConstDeclarator(node) {
|
|
555
1733
|
if (node.type !== "VariableDeclarator") return false;
|
|
@@ -557,19 +1735,6 @@ function isExportedConstDeclarator(node) {
|
|
|
557
1735
|
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") return false;
|
|
558
1736
|
return declaration.parent?.type === "ExportNamedDeclaration";
|
|
559
1737
|
}
|
|
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
1738
|
const preferLoadComponentOverLoadChildren = defineRule({
|
|
574
1739
|
meta: {
|
|
575
1740
|
type: "problem",
|
|
@@ -581,21 +1746,58 @@ const preferLoadComponentOverLoadChildren = defineRule({
|
|
|
581
1746
|
messages: { avoidLoadChildren: "Avoid loadChildren in Routes arrays; prefer loadComponent for lazy-loading standalone route components." }
|
|
582
1747
|
},
|
|
583
1748
|
createOnce(context) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1749
|
+
const imports = {
|
|
1750
|
+
routeTypeLocalNames: /* @__PURE__ */ new Set(),
|
|
1751
|
+
routesTypeLocalNames: /* @__PURE__ */ new Set(),
|
|
1752
|
+
routerNamespaces: /* @__PURE__ */ new Set()
|
|
1753
|
+
};
|
|
1754
|
+
function reportLoadChildrenInRouteObject(routeObject) {
|
|
1755
|
+
for (const property of routeObject.properties ?? []) {
|
|
1756
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
1757
|
+
const propertyName = getPropertyName(property.key);
|
|
1758
|
+
if (propertyName === "loadChildren") {
|
|
1759
|
+
context.report({
|
|
1760
|
+
node: property.key ?? property,
|
|
1761
|
+
messageId: "avoidLoadChildren"
|
|
1762
|
+
});
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
if (propertyName === "children" && property.value?.type === "ArrayExpression") reportLoadChildrenInRouteArray(property.value);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
function reportLoadChildrenInRouteArray(routeArray) {
|
|
1769
|
+
for (const element of routeArray.elements ?? []) if (element?.type === "ObjectExpression") reportLoadChildrenInRouteObject(element);
|
|
1770
|
+
}
|
|
1771
|
+
return {
|
|
1772
|
+
before() {
|
|
1773
|
+
imports.routeTypeLocalNames.clear();
|
|
1774
|
+
imports.routesTypeLocalNames.clear();
|
|
1775
|
+
imports.routerNamespaces.clear();
|
|
1776
|
+
},
|
|
1777
|
+
ImportDeclaration(node) {
|
|
1778
|
+
if (node.source?.value !== "@angular/router") return;
|
|
1779
|
+
for (const specifier of node.specifiers ?? []) {
|
|
1780
|
+
if (specifier.type === "ImportSpecifier") {
|
|
1781
|
+
const importedName = getPropertyName(specifier.imported);
|
|
1782
|
+
if (importedName === "Route") imports.routeTypeLocalNames.add(specifier.local.name);
|
|
1783
|
+
if (importedName === "Routes") imports.routesTypeLocalNames.add(specifier.local.name);
|
|
1784
|
+
}
|
|
1785
|
+
if (specifier.type === "ImportNamespaceSpecifier") {
|
|
1786
|
+
imports.routerNamespaces.add(specifier.local.name);
|
|
1787
|
+
imports.routeTypeLocalNames.add("Route");
|
|
1788
|
+
imports.routesTypeLocalNames.add("Routes");
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
},
|
|
1792
|
+
VariableDeclarator(node) {
|
|
1793
|
+
const declarator = node;
|
|
1794
|
+
if (!isExportedConstDeclarator(declarator)) return;
|
|
1795
|
+
if (declarator.id?.type !== "Identifier") return;
|
|
1796
|
+
if (!isRoutesTypeAnnotation(declarator.id, imports)) return;
|
|
1797
|
+
if (declarator.init?.type !== "ArrayExpression") return;
|
|
1798
|
+
reportLoadChildrenInRouteArray(declarator.init);
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
599
1801
|
}
|
|
600
1802
|
});
|
|
601
1803
|
//#endregion
|
|
@@ -604,6 +1806,7 @@ function getPrivateTarget(element) {
|
|
|
604
1806
|
if (element.type !== "PropertyDefinition" && element.type !== "FieldDefinition" && element.type !== "AccessorProperty" && element.type !== "MethodDefinition") return null;
|
|
605
1807
|
if (element.accessibility !== "private") return null;
|
|
606
1808
|
if (element.computed) return null;
|
|
1809
|
+
if (Array.isArray(element.decorators) && element.decorators.length > 0) return null;
|
|
607
1810
|
if (element.type === "MethodDefinition" && element.kind === "constructor") return null;
|
|
608
1811
|
const name = getPropertyName(element.key);
|
|
609
1812
|
if (!name || element.key?.type !== "Identifier") return null;
|
|
@@ -757,10 +1960,10 @@ const preferPrivateElements = defineRule({
|
|
|
757
1960
|
});
|
|
758
1961
|
//#endregion
|
|
759
1962
|
//#region src/rules/prefer-style-url/index.ts
|
|
760
|
-
function isComponentDecoratorCall(node) {
|
|
1963
|
+
function isComponentDecoratorCall(context, node, componentLocalNames, angularNamespaces) {
|
|
761
1964
|
const callee = node.callee;
|
|
762
|
-
if (callee?.type === "Identifier") return callee.name
|
|
763
|
-
return callee?.type === "MemberExpression" && getPropertyName(callee.property) === "Component";
|
|
1965
|
+
if (callee?.type === "Identifier") return componentLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
1966
|
+
return callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name) && !isShadowedIdentifier(context, callee.object) && getPropertyName(callee.property) === "Component";
|
|
764
1967
|
}
|
|
765
1968
|
function isSingleStyleFileNode(node) {
|
|
766
1969
|
if (!node) return false;
|
|
@@ -779,38 +1982,203 @@ const preferStyleUrl = defineRule({
|
|
|
779
1982
|
messages: { preferStyleUrl: "Use styleUrl instead of styleUrls when a component has one style file." }
|
|
780
1983
|
},
|
|
781
1984
|
createOnce(context) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1985
|
+
const componentLocalNames = /* @__PURE__ */ new Set();
|
|
1986
|
+
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
1987
|
+
return {
|
|
1988
|
+
before() {
|
|
1989
|
+
componentLocalNames.clear();
|
|
1990
|
+
angularNamespaces.clear();
|
|
1991
|
+
},
|
|
1992
|
+
ImportDeclaration(node) {
|
|
1993
|
+
if (node.source?.value !== "@angular/core") return;
|
|
1994
|
+
for (const specifier of node.specifiers ?? []) {
|
|
1995
|
+
if (specifier.type === "ImportSpecifier") {
|
|
1996
|
+
if (getPropertyName(specifier.imported) === "Component") componentLocalNames.add(specifier.local.name);
|
|
1997
|
+
}
|
|
1998
|
+
if (specifier.type === "ImportNamespaceSpecifier") angularNamespaces.add(specifier.local.name);
|
|
1999
|
+
}
|
|
2000
|
+
},
|
|
2001
|
+
CallExpression(node) {
|
|
2002
|
+
const call = node;
|
|
2003
|
+
if (!isComponentDecoratorCall(context, call, componentLocalNames, angularNamespaces)) return;
|
|
2004
|
+
const metadata = call.arguments?.[0];
|
|
2005
|
+
if (metadata?.type !== "ObjectExpression") return;
|
|
2006
|
+
for (const property of metadata.properties ?? []) {
|
|
2007
|
+
if (property.type !== "Property") continue;
|
|
2008
|
+
if (property.computed) continue;
|
|
2009
|
+
if (getPropertyName(property.key) !== "styleUrls") continue;
|
|
2010
|
+
if (property.value?.type !== "ArrayExpression") continue;
|
|
2011
|
+
const elements = property.value.elements?.filter(Boolean) ?? [];
|
|
2012
|
+
if (elements.length !== 1) continue;
|
|
2013
|
+
const styleFile = elements[0];
|
|
2014
|
+
if (!isSingleStyleFileNode(styleFile)) continue;
|
|
2015
|
+
const keyRange = getRange(property.key);
|
|
2016
|
+
const valueRange = getRange(property.value);
|
|
2017
|
+
const styleFileRange = getRange(styleFile);
|
|
2018
|
+
context.report({
|
|
2019
|
+
node: property.key,
|
|
2020
|
+
messageId: "preferStyleUrl",
|
|
2021
|
+
fix: keyRange && valueRange && styleFileRange ? (fixer) => [fixer.replaceTextRange(keyRange, "styleUrl"), fixer.replaceTextRange(valueRange, context.sourceCode.text.slice(styleFileRange[0], styleFileRange[1]))] : void 0
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
804
2024
|
}
|
|
805
|
-
}
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
//#endregion
|
|
2029
|
+
//#region src/rules/public-component-interface/index.ts
|
|
2030
|
+
const TARGET_DECORATORS = new Set(["Component", "Directive"]);
|
|
2031
|
+
const INPUT_MODEL_APIS = new Set(["input", "model"]);
|
|
2032
|
+
const OUTPUT_APIS = new Set(["output", "outputFromObservable"]);
|
|
2033
|
+
const INJECT_APIS = new Set(["inject"]);
|
|
2034
|
+
const FIELD_NODE_TYPES = new Set([
|
|
2035
|
+
"AccessorProperty",
|
|
2036
|
+
"FieldDefinition",
|
|
2037
|
+
"PropertyDefinition"
|
|
2038
|
+
]);
|
|
2039
|
+
function hasTargetDecorator(context, classNode, decoratorImports) {
|
|
2040
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
2041
|
+
return classNode.decorators.some((decorator) => isAngularCoreDecorator(context, decorator, decoratorImports));
|
|
2042
|
+
}
|
|
2043
|
+
function isApiCallFromTrackedImports(context, node, importedApiLocalNames, angularNamespaces, apiNames) {
|
|
2044
|
+
if (!node || node.type !== "CallExpression") return false;
|
|
2045
|
+
const callee = node.callee;
|
|
2046
|
+
if (callee?.type === "Identifier") return importedApiLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee);
|
|
2047
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
2048
|
+
const supportsRequiredApi = [...INPUT_MODEL_APIS].some((name) => apiNames.has(name));
|
|
2049
|
+
if (getPropertyName(callee.property) === "required" && callee.object?.type === "Identifier") return supportsRequiredApi && importedApiLocalNames.has(callee.object.name) && !isShadowedIdentifier(context, callee.object);
|
|
2050
|
+
if (callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name)) {
|
|
2051
|
+
const namespaceApiName = getPropertyName(callee.property);
|
|
2052
|
+
return !!namespaceApiName && apiNames.has(namespaceApiName) && !isShadowedIdentifier(context, callee.object);
|
|
2053
|
+
}
|
|
2054
|
+
if (getPropertyName(callee.property) === "required" && callee.object?.type === "MemberExpression" && callee.object.object?.type === "Identifier" && angularNamespaces.has(callee.object.object.name)) {
|
|
2055
|
+
const namespaceApiName = getPropertyName(callee.object.property);
|
|
2056
|
+
return supportsRequiredApi && !!namespaceApiName && apiNames.has(namespaceApiName) && !isShadowedIdentifier(context, callee.object.object);
|
|
2057
|
+
}
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
function isNonPublicMember(memberNode) {
|
|
2061
|
+
if (memberNode.key?.type === "PrivateIdentifier") return true;
|
|
2062
|
+
return memberNode.accessibility === "private" || memberNode.accessibility === "protected";
|
|
2063
|
+
}
|
|
2064
|
+
function isPublicMember(memberNode) {
|
|
2065
|
+
if (memberNode.key?.type === "PrivateIdentifier") return false;
|
|
2066
|
+
return memberNode.accessibility !== "private" && memberNode.accessibility !== "protected";
|
|
2067
|
+
}
|
|
2068
|
+
function getAccessibilityModifierRange(context, memberNode) {
|
|
2069
|
+
const memberRange = getRange(memberNode);
|
|
2070
|
+
const keyRange = getRange(memberNode.key);
|
|
2071
|
+
if (!memberRange || !keyRange) return null;
|
|
2072
|
+
const prefixText = context.sourceCode.text.slice(memberRange[0], keyRange[0]);
|
|
2073
|
+
const match = /\b(private|protected|public)\b/u.exec(prefixText);
|
|
2074
|
+
if (!match) return null;
|
|
2075
|
+
return [memberRange[0] + match.index, memberRange[0] + match.index + match[0].length];
|
|
2076
|
+
}
|
|
2077
|
+
function getVisibilityFix(context, memberNode, targetVisibility) {
|
|
2078
|
+
if (memberNode.key?.type === "PrivateIdentifier") return void 0;
|
|
2079
|
+
const keyRange = getRange(memberNode.key);
|
|
2080
|
+
if (!keyRange) return void 0;
|
|
2081
|
+
const accessibilityRange = getAccessibilityModifierRange(context, memberNode);
|
|
2082
|
+
if (accessibilityRange) return (fixer) => fixer.replaceTextRange(accessibilityRange, targetVisibility);
|
|
2083
|
+
return (fixer) => fixer.insertTextBeforeRange([keyRange[0], keyRange[0]], `${targetVisibility} `);
|
|
2084
|
+
}
|
|
2085
|
+
const publicComponentInterface = defineRule({
|
|
2086
|
+
meta: {
|
|
2087
|
+
type: "problem",
|
|
2088
|
+
docs: {
|
|
2089
|
+
description: "Require component/directive signal interface members (input/model/output APIs) to be public, and it's dependencies to be non-public.",
|
|
2090
|
+
recommended: true
|
|
2091
|
+
},
|
|
2092
|
+
fixable: "code",
|
|
2093
|
+
schema: [],
|
|
2094
|
+
messages: {
|
|
2095
|
+
nonPublicInputModel: "Input/model member {{name}} must be public so the component/directive interface is externally accessible.",
|
|
2096
|
+
nonPublicOutput: "Output member {{name}} must be public so the component/directive interface is externally accessible.",
|
|
2097
|
+
publicInjectMember: "Injected member {{name}} should not be public; prefer protected (template access) or private/#private."
|
|
2098
|
+
}
|
|
2099
|
+
},
|
|
2100
|
+
createOnce(context) {
|
|
2101
|
+
const inputModelLocalNames = /* @__PURE__ */ new Set();
|
|
2102
|
+
const outputLocalNames = /* @__PURE__ */ new Set();
|
|
2103
|
+
const injectLocalNames = /* @__PURE__ */ new Set();
|
|
2104
|
+
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
2105
|
+
const decoratorImports = {
|
|
2106
|
+
decoratorNames: TARGET_DECORATORS,
|
|
2107
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
2108
|
+
angularNamespaces
|
|
2109
|
+
};
|
|
2110
|
+
return {
|
|
2111
|
+
before() {
|
|
2112
|
+
inputModelLocalNames.clear();
|
|
2113
|
+
outputLocalNames.clear();
|
|
2114
|
+
injectLocalNames.clear();
|
|
2115
|
+
angularNamespaces.clear();
|
|
2116
|
+
decoratorImports.decoratorLocalNames.clear();
|
|
2117
|
+
},
|
|
2118
|
+
ImportDeclaration(node) {
|
|
2119
|
+
if (node.source?.value !== "@angular/core") return;
|
|
2120
|
+
for (const specifier of node.specifiers ?? []) {
|
|
2121
|
+
addAngularCoreDecoratorImport(specifier, TARGET_DECORATORS, decoratorImports);
|
|
2122
|
+
if (specifier.type === "ImportSpecifier") {
|
|
2123
|
+
const importedName = getPropertyName(specifier.imported);
|
|
2124
|
+
if (!importedName) continue;
|
|
2125
|
+
if (INPUT_MODEL_APIS.has(importedName)) inputModelLocalNames.add(specifier.local.name);
|
|
2126
|
+
if (OUTPUT_APIS.has(importedName)) outputLocalNames.add(specifier.local.name);
|
|
2127
|
+
if (INJECT_APIS.has(importedName)) injectLocalNames.add(specifier.local.name);
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
if (specifier.type === "ImportNamespaceSpecifier") angularNamespaces.add(specifier.local.name);
|
|
2131
|
+
}
|
|
2132
|
+
},
|
|
2133
|
+
ClassBody(node) {
|
|
2134
|
+
const classNode = node.parent;
|
|
2135
|
+
if (!hasTargetDecorator(context, classNode, decoratorImports)) return;
|
|
2136
|
+
for (const member of node.body ?? []) {
|
|
2137
|
+
if (!FIELD_NODE_TYPES.has(member.type)) continue;
|
|
2138
|
+
const isInputModelMember = isApiCallFromTrackedImports(context, member.value, inputModelLocalNames, angularNamespaces, INPUT_MODEL_APIS);
|
|
2139
|
+
const isOutputMember = isApiCallFromTrackedImports(context, member.value, outputLocalNames, angularNamespaces, OUTPUT_APIS);
|
|
2140
|
+
if (isInputModelMember && isNonPublicMember(member)) {
|
|
2141
|
+
context.report({
|
|
2142
|
+
node: member.key ?? member,
|
|
2143
|
+
messageId: "nonPublicInputModel",
|
|
2144
|
+
data: { name: getPropertyName(member.key) ?? "member" },
|
|
2145
|
+
fix: getVisibilityFix(context, member, "public")
|
|
2146
|
+
});
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
if (isOutputMember && isNonPublicMember(member)) {
|
|
2150
|
+
context.report({
|
|
2151
|
+
node: member.key ?? member,
|
|
2152
|
+
messageId: "nonPublicOutput",
|
|
2153
|
+
data: { name: getPropertyName(member.key) ?? "member" },
|
|
2154
|
+
fix: getVisibilityFix(context, member, "public")
|
|
2155
|
+
});
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
if (isPublicMember(member) && isApiCallFromTrackedImports(context, member.value, injectLocalNames, angularNamespaces, INJECT_APIS)) context.report({
|
|
2159
|
+
node: member.key ?? member,
|
|
2160
|
+
messageId: "publicInjectMember",
|
|
2161
|
+
data: { name: getPropertyName(member.key) ?? "member" },
|
|
2162
|
+
fix: getVisibilityFix(context, member, "protected")
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
},
|
|
2166
|
+
"Program:exit"() {
|
|
2167
|
+
inputModelLocalNames.clear();
|
|
2168
|
+
outputLocalNames.clear();
|
|
2169
|
+
injectLocalNames.clear();
|
|
2170
|
+
angularNamespaces.clear();
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
806
2173
|
}
|
|
807
2174
|
});
|
|
808
2175
|
//#endregion
|
|
809
2176
|
//#region src/rules/restrict-injectable-provided-in/index.ts
|
|
810
2177
|
const ALLOWED_PROVIDED_IN_VALUES = new Set(["root", "platform"]);
|
|
811
|
-
|
|
2178
|
+
const INJECTABLE_DECORATORS = new Set(["Injectable"]);
|
|
2179
|
+
function getInjectableMetadata(context, node, decoratorImports) {
|
|
812
2180
|
if (node.type !== "Decorator") return null;
|
|
813
|
-
if (
|
|
2181
|
+
if (!isAngularCoreDecorator(context, node, decoratorImports)) return null;
|
|
814
2182
|
const expression = node.expression;
|
|
815
2183
|
if (expression?.type !== "CallExpression") return null;
|
|
816
2184
|
const metadata = expression.arguments?.[0];
|
|
@@ -832,23 +2200,38 @@ const restrictInjectableProvidedIn = defineRule({
|
|
|
832
2200
|
recommended: true
|
|
833
2201
|
},
|
|
834
2202
|
schema: [],
|
|
835
|
-
messages: { disallowedProvidedIn: "@Injectable providedIn should be 'root' or 'platform', not {{actual}}." }
|
|
2203
|
+
messages: { disallowedProvidedIn: "@Injectable providedIn should be the literal 'root' or 'platform', not {{actual}}." }
|
|
836
2204
|
},
|
|
837
2205
|
createOnce(context) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
2206
|
+
const decoratorImports = {
|
|
2207
|
+
decoratorNames: INJECTABLE_DECORATORS,
|
|
2208
|
+
decoratorLocalNames: /* @__PURE__ */ new Set(),
|
|
2209
|
+
angularNamespaces: /* @__PURE__ */ new Set()
|
|
2210
|
+
};
|
|
2211
|
+
return {
|
|
2212
|
+
before() {
|
|
2213
|
+
decoratorImports.decoratorLocalNames.clear();
|
|
2214
|
+
decoratorImports.angularNamespaces.clear();
|
|
2215
|
+
},
|
|
2216
|
+
ImportDeclaration(node) {
|
|
2217
|
+
if (node.source?.value !== "@angular/core") return;
|
|
2218
|
+
for (const specifier of node.specifiers ?? []) addAngularCoreDecoratorImport(specifier, INJECTABLE_DECORATORS, decoratorImports);
|
|
2219
|
+
},
|
|
2220
|
+
Decorator(node) {
|
|
2221
|
+
const metadata = getInjectableMetadata(context, node, decoratorImports);
|
|
2222
|
+
if (!metadata) return;
|
|
2223
|
+
const providedInProperty = getProvidedInProperty(metadata);
|
|
2224
|
+
if (!providedInProperty) return;
|
|
2225
|
+
const value = providedInProperty.value;
|
|
2226
|
+
if (isAllowedProvidedInValue(value)) return;
|
|
2227
|
+
const actual = context.sourceCode.text.slice(value?.range?.[0] ?? providedInProperty.range?.[0] ?? 0, value?.range?.[1] ?? providedInProperty.range?.[1] ?? 0);
|
|
2228
|
+
context.report({
|
|
2229
|
+
node: value ?? providedInProperty,
|
|
2230
|
+
messageId: "disallowedProvidedIn",
|
|
2231
|
+
data: { actual: actual || "this value" }
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
852
2235
|
}
|
|
853
2236
|
});
|
|
854
2237
|
//#endregion
|
|
@@ -878,6 +2261,37 @@ const ANGULAR_CLASS_DECORATOR_NAMES = new Set([
|
|
|
878
2261
|
"NgModule"
|
|
879
2262
|
]);
|
|
880
2263
|
const INJECTION_CONTEXT_RUNNER_NAMES = new Set(["runInInjectionContext", "runInContext"]);
|
|
2264
|
+
const KNOWN_INJECTION_CONTEXT_API_IMPORTS = [
|
|
2265
|
+
{
|
|
2266
|
+
from: "@angular/core",
|
|
2267
|
+
imports: [
|
|
2268
|
+
"afterEveryRender",
|
|
2269
|
+
"afterNextRender",
|
|
2270
|
+
"afterRender",
|
|
2271
|
+
"afterRenderEffect",
|
|
2272
|
+
"assertInInjectionContext",
|
|
2273
|
+
"effect",
|
|
2274
|
+
"inject",
|
|
2275
|
+
"resource"
|
|
2276
|
+
]
|
|
2277
|
+
},
|
|
2278
|
+
{
|
|
2279
|
+
from: "@angular/core/rxjs-interop",
|
|
2280
|
+
imports: [
|
|
2281
|
+
"rxResource",
|
|
2282
|
+
"toObservable",
|
|
2283
|
+
"toSignal"
|
|
2284
|
+
]
|
|
2285
|
+
},
|
|
2286
|
+
{
|
|
2287
|
+
from: "@angular/common/http",
|
|
2288
|
+
imports: ["httpResource"]
|
|
2289
|
+
},
|
|
2290
|
+
{
|
|
2291
|
+
from: "@angular/forms/signals",
|
|
2292
|
+
imports: ["form"]
|
|
2293
|
+
}
|
|
2294
|
+
];
|
|
881
2295
|
const INJECTION_CONTEXT_FUNCTION_TYPE_NAMES = new Set([
|
|
882
2296
|
"CanActivateFn",
|
|
883
2297
|
"CanActivateChildFn",
|
|
@@ -898,17 +2312,6 @@ const CLASS_FIELD_TYPES = new Set([
|
|
|
898
2312
|
"FieldDefinition",
|
|
899
2313
|
"PropertyDefinition"
|
|
900
2314
|
]);
|
|
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
2315
|
function isFunction(node) {
|
|
913
2316
|
return !!node && FUNCTION_TYPES.has(node.type);
|
|
914
2317
|
}
|
|
@@ -975,11 +2378,6 @@ function isTypedInjectionContextFunction(functionNode) {
|
|
|
975
2378
|
const unqualifiedTypeName = typeName.includes(".") ? typeName.split(".").at(-1) : typeName;
|
|
976
2379
|
return INJECTION_CONTEXT_FUNCTION_TYPE_NAMES.has(unqualifiedTypeName ?? typeName);
|
|
977
2380
|
}
|
|
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
2381
|
function hasAwaitBeforeNodeInFunction(functionNode, node) {
|
|
984
2382
|
const nodeStart = getNodeStart(node);
|
|
985
2383
|
if (nodeStart === null) return false;
|
|
@@ -1027,16 +2425,27 @@ function isAllowedInjectionContext(context, node, allowedFunctionNames, injectFu
|
|
|
1027
2425
|
const functionName = getFunctionName(nearestFunction);
|
|
1028
2426
|
return functionName ? allowedFunctionNames.has(functionName) || injectFunctionPrefixes.some((prefix) => functionName.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => functionName.endsWith(suffix)) : false;
|
|
1029
2427
|
}
|
|
1030
|
-
function
|
|
2428
|
+
function getKnownInjectionContextApiImports(source) {
|
|
2429
|
+
const apiImports = KNOWN_INJECTION_CONTEXT_API_IMPORTS.find((entry) => entry.from === source);
|
|
2430
|
+
return apiImports ? new Set(apiImports.imports) : null;
|
|
2431
|
+
}
|
|
2432
|
+
function isKnownInjectionContextApiCall(context, node, injectionContextApiLocalNames, injectionContextApiNamespaceMembers, checkUnimportedInject) {
|
|
1031
2433
|
const callee = node.callee;
|
|
1032
|
-
if (callee?.type === "Identifier") return
|
|
1033
|
-
|
|
2434
|
+
if (callee?.type === "Identifier") return injectionContextApiLocalNames.has(callee.name) && !isShadowedIdentifier(context, callee) || checkUnimportedInject && callee.name === "inject" && !isShadowedIdentifier(context, callee);
|
|
2435
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
2436
|
+
if (callee.object?.type === "Identifier") {
|
|
2437
|
+
if (injectionContextApiLocalNames.has(callee.object.name) && !isShadowedIdentifier(context, callee.object)) return true;
|
|
2438
|
+
return !!injectionContextApiNamespaceMembers.get(callee.object.name)?.has(getPropertyName(callee.property) ?? "") && !isShadowedIdentifier(context, callee.object);
|
|
2439
|
+
}
|
|
2440
|
+
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);
|
|
2441
|
+
return false;
|
|
1034
2442
|
}
|
|
1035
|
-
function isInjectLikeHelperCall(node,
|
|
2443
|
+
function isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) {
|
|
1036
2444
|
const callee = node.callee;
|
|
1037
2445
|
if (callee?.type !== "Identifier") return false;
|
|
2446
|
+
if (isShadowedIdentifier(context, callee)) return false;
|
|
1038
2447
|
if (runsInInjectionContextFunctionNames.has(callee.name)) return true;
|
|
1039
|
-
return !
|
|
2448
|
+
return !injectionContextApiLocalNames.has(callee.name) && callee.name !== "inject" && (injectFunctionPrefixes.some((prefix) => callee.name.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => callee.name.endsWith(suffix)));
|
|
1040
2449
|
}
|
|
1041
2450
|
//#endregion
|
|
1042
2451
|
//#region src/index.ts
|
|
@@ -1044,19 +2453,23 @@ const plugin = eslintCompatPlugin({
|
|
|
1044
2453
|
meta: { name: "@benjavicente/lint-angular" },
|
|
1045
2454
|
rules: {
|
|
1046
2455
|
"avoid-explicit-injection-context": avoidExplicitInjectionContext,
|
|
2456
|
+
"avoid-explicit-subscription-management": avoidExplicitSubscriptionManagement,
|
|
1047
2457
|
"avoid-ng-modules": avoidNgModules,
|
|
2458
|
+
"avoid-rxjs-state-in-component": avoidRxjsStateInComponent,
|
|
1048
2459
|
"avoid-writing-signals-in-reactive-context": avoidWritingSignalsInReactiveContext,
|
|
1049
2460
|
"class-member-order": classMemberOrder,
|
|
1050
2461
|
"component-class-matches-filename": componentClassMatchesFilename,
|
|
2462
|
+
"component-resource-filenames": componentResourceFilenames,
|
|
1051
2463
|
"prefer-load-component-over-load-children": preferLoadComponentOverLoadChildren,
|
|
1052
2464
|
"prefer-private-elements": preferPrivateElements,
|
|
1053
2465
|
"prefer-style-url": preferStyleUrl,
|
|
2466
|
+
"public-component-interface": publicComponentInterface,
|
|
1054
2467
|
"restrict-injectable-provided-in": restrictInjectableProvidedIn,
|
|
1055
2468
|
"rules-of-inject": defineRule({
|
|
1056
2469
|
meta: {
|
|
1057
2470
|
type: "problem",
|
|
1058
2471
|
docs: {
|
|
1059
|
-
description: "Require Angular
|
|
2472
|
+
description: "Require Angular APIs that depend on injection context to appear only in known injection contexts.",
|
|
1060
2473
|
recommended: true
|
|
1061
2474
|
},
|
|
1062
2475
|
schema: [{
|
|
@@ -1100,17 +2513,17 @@ const plugin = eslintCompatPlugin({
|
|
|
1100
2513
|
}
|
|
1101
2514
|
}
|
|
1102
2515
|
}],
|
|
1103
|
-
messages: { disallowedInject: "Angular
|
|
2516
|
+
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
2517
|
},
|
|
1105
2518
|
createOnce(context) {
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
2519
|
+
const injectionContextApiLocalNames = /* @__PURE__ */ new Set();
|
|
2520
|
+
const injectionContextApiNamespaceMembers = /* @__PURE__ */ new Map();
|
|
1108
2521
|
const runsInInjectionContextFunctionNames = /* @__PURE__ */ new Set();
|
|
1109
2522
|
let runsInInjectionContextRules = [];
|
|
1110
2523
|
return {
|
|
1111
2524
|
before() {
|
|
1112
|
-
|
|
1113
|
-
|
|
2525
|
+
injectionContextApiLocalNames.clear();
|
|
2526
|
+
injectionContextApiNamespaceMembers.clear();
|
|
1114
2527
|
runsInInjectionContextFunctionNames.clear();
|
|
1115
2528
|
runsInInjectionContextRules = (context.options[0] ?? {}).runsInInjectionContext ?? DEFAULT_RUNS_IN_INJECTION_CONTEXT;
|
|
1116
2529
|
},
|
|
@@ -1121,10 +2534,10 @@ const plugin = eslintCompatPlugin({
|
|
|
1121
2534
|
if (specifier.type === "ImportSpecifier" && (matchingRule.imports === "all" || matchingRule.imports.includes(getPropertyName(specifier.imported) ?? ""))) runsInInjectionContextFunctionNames.add(specifier.local.name);
|
|
1122
2535
|
if ((specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") && matchingRule.imports === "all") runsInInjectionContextFunctionNames.add(specifier.local.name);
|
|
1123
2536
|
}
|
|
1124
|
-
|
|
1125
|
-
for (const specifier of node.specifiers ?? []) {
|
|
1126
|
-
if (specifier.type === "ImportSpecifier" && getPropertyName(specifier.imported)
|
|
1127
|
-
if (specifier.type === "ImportNamespaceSpecifier")
|
|
2537
|
+
const knownApiImports = typeof source === "string" ? getKnownInjectionContextApiImports(source) : null;
|
|
2538
|
+
if (knownApiImports) for (const specifier of node.specifiers ?? []) {
|
|
2539
|
+
if (specifier.type === "ImportSpecifier" && knownApiImports.has(getPropertyName(specifier.imported) ?? "")) injectionContextApiLocalNames.add(specifier.local.name);
|
|
2540
|
+
if (specifier.type === "ImportNamespaceSpecifier") injectionContextApiNamespaceMembers.set(specifier.local.name, knownApiImports);
|
|
1128
2541
|
}
|
|
1129
2542
|
},
|
|
1130
2543
|
CallExpression(node) {
|
|
@@ -1134,14 +2547,14 @@ const plugin = eslintCompatPlugin({
|
|
|
1134
2547
|
const injectFunctionPrefixes = options.injectFunctionPrefixes ?? DEFAULT_INJECT_FUNCTION_PREFIXES;
|
|
1135
2548
|
const injectFunctionSuffixes = options.injectFunctionSuffixes ?? DEFAULT_INJECT_FUNCTION_SUFFIXES;
|
|
1136
2549
|
const inAllowedContext = isAllowedInjectionContext(context, node, allowedFunctionNames, injectFunctionPrefixes, injectFunctionSuffixes);
|
|
1137
|
-
if (isInjectLikeHelperCall(node,
|
|
2550
|
+
if (isInjectLikeHelperCall(context, node, injectionContextApiLocalNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) && !inAllowedContext) {
|
|
1138
2551
|
context.report({
|
|
1139
2552
|
node: node.callee,
|
|
1140
2553
|
messageId: "disallowedInject"
|
|
1141
2554
|
});
|
|
1142
2555
|
return;
|
|
1143
2556
|
}
|
|
1144
|
-
if (!
|
|
2557
|
+
if (!isKnownInjectionContextApiCall(context, node, injectionContextApiLocalNames, injectionContextApiNamespaceMembers, checkUnimportedInject)) return;
|
|
1145
2558
|
if (inAllowedContext) return;
|
|
1146
2559
|
context.report({
|
|
1147
2560
|
node: node.callee,
|