@benjavicente/lint-angular 0.0.1
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 +29 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +1157 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# `@benjavicente/lint-angular`
|
|
2
|
+
|
|
3
|
+
Oxlint/ESLint-compatible plugin for Angular project rules.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
|
|
7
|
+
| Rule | Default | Fixable |
|
|
8
|
+
| ----------------------------------------------------------------------------------------------------- | ------- | ------- |
|
|
9
|
+
| [`rules-of-inject`](./src/rules/rules-of-inject/) | ✅ | |
|
|
10
|
+
| [`avoid-explicit-injection-context`](./src/rules/avoid-explicit-injection-context/) | ✅ | |
|
|
11
|
+
| [`avoid-ng-modules`](./src/rules/avoid-ng-modules/) | ✅ | |
|
|
12
|
+
| [`avoid-writing-signals-in-reactive-context`](./src/rules/avoid-writing-signals-in-reactive-context/) | ✅ | |
|
|
13
|
+
| [`class-member-order`](./src/rules/class-member-order/) | ✅ | |
|
|
14
|
+
| [`component-class-matches-filename`](./src/rules/component-class-matches-filename/) | ✅ | |
|
|
15
|
+
| [`prefer-private-elements`](./src/rules/prefer-private-elements/) | ✅ | ✅ |
|
|
16
|
+
| [`prefer-load-component-over-load-children`](./src/rules/prefer-load-component-over-load-children/) | ✅ | |
|
|
17
|
+
| [`prefer-style-url`](./src/rules/prefer-style-url/) | ✅ | ✅ |
|
|
18
|
+
| [`restrict-injectable-provided-in`](./src/rules/restrict-injectable-provided-in/) | ✅ | |
|
|
19
|
+
|
|
20
|
+
## Oxlint setup
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"jsPlugins": ["@benjavicente/lint-angular"],
|
|
25
|
+
"rules": {
|
|
26
|
+
"@benjavicente/lint-angular/rules-of-inject": "error",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
```
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
|
|
2
|
+
//#region src/utilities/ast.ts
|
|
3
|
+
function getPropertyName(node) {
|
|
4
|
+
if (!node) return null;
|
|
5
|
+
if (node.type === "Identifier") return node.name;
|
|
6
|
+
if (node.type === "PrivateIdentifier") return node.name;
|
|
7
|
+
if (node.type === "Literal") return String(node.value);
|
|
8
|
+
if (node.type === "StringLiteral") return String(node.value);
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function getDecoratorName(decoratorNode) {
|
|
12
|
+
if (!decoratorNode) return null;
|
|
13
|
+
const expression = decoratorNode.expression ?? decoratorNode;
|
|
14
|
+
const callee = expression?.type === "CallExpression" ? expression.callee : expression;
|
|
15
|
+
if (!callee) return null;
|
|
16
|
+
if (callee.type === "Identifier") return callee.name;
|
|
17
|
+
if (callee.type === "MemberExpression") return getPropertyName(callee.property);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function getRange(node) {
|
|
21
|
+
if (!node) return null;
|
|
22
|
+
if (Array.isArray(node.range) && typeof node.range[0] === "number") return [node.range[0], node.range[1]];
|
|
23
|
+
if (typeof node.start === "number" && typeof node.end === "number") return [node.start, node.end];
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/rules/class-member-order/index.ts
|
|
28
|
+
const ANGULAR_CLASS_DECORATOR_NAMES$1 = new Set([
|
|
29
|
+
"Component",
|
|
30
|
+
"Directive",
|
|
31
|
+
"Injectable",
|
|
32
|
+
"Pipe",
|
|
33
|
+
"NgModule"
|
|
34
|
+
]);
|
|
35
|
+
const INPUT_MODEL_CALL_NAMES = new Set(["input", "model"]);
|
|
36
|
+
const OUTPUT_CALL_NAMES = new Set(["output", "outputFromObservable"]);
|
|
37
|
+
const CLASS_FIELD_TYPES$1 = new Set([
|
|
38
|
+
"AccessorProperty",
|
|
39
|
+
"FieldDefinition",
|
|
40
|
+
"PropertyDefinition"
|
|
41
|
+
]);
|
|
42
|
+
const ORDER_LABELS = [
|
|
43
|
+
"plain inject fields",
|
|
44
|
+
"inputs/models",
|
|
45
|
+
"outputs",
|
|
46
|
+
"everything else"
|
|
47
|
+
];
|
|
48
|
+
function hasAngularClassDecorator(classNode) {
|
|
49
|
+
if (!classNode || !Array.isArray(classNode.decorators)) return false;
|
|
50
|
+
return classNode.decorators.some((decorator) => ANGULAR_CLASS_DECORATOR_NAMES$1.has(getDecoratorName(decorator) ?? ""));
|
|
51
|
+
}
|
|
52
|
+
function getCallName(node) {
|
|
53
|
+
if (!node || node.type !== "CallExpression") return null;
|
|
54
|
+
const callee = node.callee;
|
|
55
|
+
if (callee?.type === "Identifier") return callee.name;
|
|
56
|
+
if (callee?.type === "MemberExpression") return getPropertyName(callee.property);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function hasDecorator(element, names) {
|
|
60
|
+
return Array.isArray(element.decorators) ? element.decorators.some((decorator) => names.has(getDecoratorName(decorator) ?? "")) : false;
|
|
61
|
+
}
|
|
62
|
+
function isDirectInjectCall(value) {
|
|
63
|
+
if (!value || value.type !== "CallExpression") return false;
|
|
64
|
+
const callee = value.callee;
|
|
65
|
+
return callee?.type === "Identifier" && callee.name === "inject" || callee?.type === "MemberExpression" && getPropertyName(callee.property) === "inject";
|
|
66
|
+
}
|
|
67
|
+
function classifyMember(element) {
|
|
68
|
+
if (CLASS_FIELD_TYPES$1.has(element.type)) {
|
|
69
|
+
const callName = getCallName(element.value);
|
|
70
|
+
if (isDirectInjectCall(element.value)) return 0;
|
|
71
|
+
if (callName && INPUT_MODEL_CALL_NAMES.has(callName)) return 1;
|
|
72
|
+
if (hasDecorator(element, new Set(["Input"]))) return 1;
|
|
73
|
+
if (callName && OUTPUT_CALL_NAMES.has(callName)) return 2;
|
|
74
|
+
if (hasDecorator(element, new Set(["Output"]))) return 2;
|
|
75
|
+
return 3;
|
|
76
|
+
}
|
|
77
|
+
if (element.type === "MethodDefinition") return 3;
|
|
78
|
+
return 3;
|
|
79
|
+
}
|
|
80
|
+
const classMemberOrder = defineRule({
|
|
81
|
+
meta: {
|
|
82
|
+
type: "suggestion",
|
|
83
|
+
docs: {
|
|
84
|
+
description: "Require Angular class members to be ordered as inject fields, inputs/models, outputs, then everything else.",
|
|
85
|
+
recommended: true
|
|
86
|
+
},
|
|
87
|
+
schema: [],
|
|
88
|
+
messages: { outOfOrder: "Angular class member should be ordered before {{previousGroup}} and with {{expectedGroup}}." }
|
|
89
|
+
},
|
|
90
|
+
createOnce(context) {
|
|
91
|
+
return { ClassBody(node) {
|
|
92
|
+
if (!hasAngularClassDecorator(node.parent)) return;
|
|
93
|
+
let highestSeen = null;
|
|
94
|
+
for (const element of node.body ?? []) {
|
|
95
|
+
const group = classifyMember(element);
|
|
96
|
+
if (group === null) continue;
|
|
97
|
+
if (highestSeen !== null && group < highestSeen) {
|
|
98
|
+
context.report({
|
|
99
|
+
node: element,
|
|
100
|
+
messageId: "outOfOrder",
|
|
101
|
+
data: {
|
|
102
|
+
expectedGroup: ORDER_LABELS[group],
|
|
103
|
+
previousGroup: ORDER_LABELS[highestSeen]
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
highestSeen = group;
|
|
109
|
+
}
|
|
110
|
+
} };
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/rules/avoid-explicit-injection-context/index.ts
|
|
115
|
+
const DEFAULT_DISALLOW_INJECT_INJECTOR = true;
|
|
116
|
+
const DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT = true;
|
|
117
|
+
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;
|
|
120
|
+
}
|
|
121
|
+
function isInjectCall(callNode, injectNames, angularNamespaces) {
|
|
122
|
+
const callee = callNode.callee;
|
|
123
|
+
return callee?.type === "Identifier" && injectNames.has(callee.name) || isAngularNamespaceMember(callee, angularNamespaces, "inject");
|
|
124
|
+
}
|
|
125
|
+
function isInjectorReference(node, injectorNames, angularNamespaces) {
|
|
126
|
+
return node?.type === "Identifier" && injectorNames.has(node.name) || isAngularNamespaceMember(node, angularNamespaces, "Injector");
|
|
127
|
+
}
|
|
128
|
+
function isDisallowedInjectInjector(callNode, injectNames, injectorNames, angularNamespaces) {
|
|
129
|
+
if (!isInjectCall(callNode, injectNames, angularNamespaces)) return false;
|
|
130
|
+
return isInjectorReference(callNode.arguments?.[0], injectorNames, angularNamespaces);
|
|
131
|
+
}
|
|
132
|
+
function isDisallowedRunInInjectionContext(callNode, runInInjectionContextNames, runInContextNames, angularNamespaces) {
|
|
133
|
+
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) ?? "");
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const avoidExplicitInjectionContext = defineRule({
|
|
139
|
+
meta: {
|
|
140
|
+
type: "suggestion",
|
|
141
|
+
docs: {
|
|
142
|
+
description: "Avoid explicit injection-context APIs such as inject(Injector) and runInInjectionContext/runInContext.",
|
|
143
|
+
recommended: true
|
|
144
|
+
},
|
|
145
|
+
schema: [{
|
|
146
|
+
type: "object",
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
properties: {
|
|
149
|
+
disallowInjectInjector: {
|
|
150
|
+
type: "boolean",
|
|
151
|
+
default: DEFAULT_DISALLOW_INJECT_INJECTOR
|
|
152
|
+
},
|
|
153
|
+
disallowRunInInjectionContext: {
|
|
154
|
+
type: "boolean",
|
|
155
|
+
default: DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}],
|
|
159
|
+
messages: {
|
|
160
|
+
avoidInjectInjector: "Avoid inject(Injector). Prefer APIs that run in the ambient Angular injection context.",
|
|
161
|
+
avoidRunInInjectionContext: "Avoid runInInjectionContext/runInContext. Prefer APIs that run in the ambient Angular injection context."
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
createOnce(context) {
|
|
165
|
+
const injectNames = /* @__PURE__ */ new Set();
|
|
166
|
+
const injectorNames = /* @__PURE__ */ new Set();
|
|
167
|
+
const runInInjectionContextNames = /* @__PURE__ */ new Set();
|
|
168
|
+
const runInContextNames = /* @__PURE__ */ new Set();
|
|
169
|
+
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
170
|
+
return {
|
|
171
|
+
before() {
|
|
172
|
+
injectNames.clear();
|
|
173
|
+
injectorNames.clear();
|
|
174
|
+
runInInjectionContextNames.clear();
|
|
175
|
+
runInContextNames.clear();
|
|
176
|
+
angularNamespaces.clear();
|
|
177
|
+
},
|
|
178
|
+
ImportDeclaration(node) {
|
|
179
|
+
if (node.source?.value !== "@angular/core") return;
|
|
180
|
+
for (const specifier of node.specifiers ?? []) {
|
|
181
|
+
if (specifier.type === "ImportSpecifier") {
|
|
182
|
+
const importedName = getPropertyName(specifier.imported);
|
|
183
|
+
if (importedName === "inject") injectNames.add(specifier.local.name);
|
|
184
|
+
if (importedName === "Injector") injectorNames.add(specifier.local.name);
|
|
185
|
+
if (importedName === "runInInjectionContext") runInInjectionContextNames.add(specifier.local.name);
|
|
186
|
+
if (importedName === "runInContext") runInContextNames.add(specifier.local.name);
|
|
187
|
+
}
|
|
188
|
+
if (specifier.type === "ImportNamespaceSpecifier") angularNamespaces.add(specifier.local.name);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
CallExpression(node) {
|
|
192
|
+
const callNode = node;
|
|
193
|
+
const options = context.options[0] ?? {};
|
|
194
|
+
const disallowInjectInjector = options.disallowInjectInjector ?? DEFAULT_DISALLOW_INJECT_INJECTOR;
|
|
195
|
+
const disallowRunInInjectionContext = options.disallowRunInInjectionContext ?? DEFAULT_DISALLOW_RUN_IN_INJECTION_CONTEXT;
|
|
196
|
+
if (disallowInjectInjector && isDisallowedInjectInjector(callNode, injectNames, injectorNames, angularNamespaces)) context.report({
|
|
197
|
+
node: callNode.callee,
|
|
198
|
+
messageId: "avoidInjectInjector"
|
|
199
|
+
});
|
|
200
|
+
if (disallowRunInInjectionContext && isDisallowedRunInInjectionContext(callNode, runInInjectionContextNames, runInContextNames, angularNamespaces)) context.report({
|
|
201
|
+
node: callNode.callee,
|
|
202
|
+
messageId: "avoidRunInInjectionContext"
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/rules/avoid-ng-modules/index.ts
|
|
210
|
+
const DEFAULT_ALLOW_FOR_GROUPING = true;
|
|
211
|
+
const DEFAULT_ALLOW_FOR_PROVIDING = false;
|
|
212
|
+
const DEFAULT_ALLOW_FOR_ROUTING = false;
|
|
213
|
+
function getNgModuleMetadata(node) {
|
|
214
|
+
if (node.type !== "Decorator") return null;
|
|
215
|
+
if (getDecoratorName(node) !== "NgModule") return null;
|
|
216
|
+
const expression = node.expression;
|
|
217
|
+
if (expression?.type !== "CallExpression") return null;
|
|
218
|
+
const metadata = expression.arguments?.[0];
|
|
219
|
+
return metadata?.type === "ObjectExpression" ? metadata : null;
|
|
220
|
+
}
|
|
221
|
+
function getArrayElementsFromProperty(metadata, propertyName) {
|
|
222
|
+
const property = (metadata.properties ?? []).find((candidate) => candidate.type === "Property" && !candidate.computed && getPropertyName(candidate.key) === propertyName && candidate.value?.type === "ArrayExpression");
|
|
223
|
+
if (!property) return [];
|
|
224
|
+
return property.value.elements?.filter(Boolean) ?? [];
|
|
225
|
+
}
|
|
226
|
+
function isMemberCall(node, methodName) {
|
|
227
|
+
return node.type === "CallExpression" && getPropertyName(node.callee?.property) === methodName;
|
|
228
|
+
}
|
|
229
|
+
function isRouterModuleForChild(node) {
|
|
230
|
+
return isMemberCall(node, "forChild") && node.callee?.type === "MemberExpression" && getPropertyName(node.callee.object) === "RouterModule";
|
|
231
|
+
}
|
|
232
|
+
function getNgModuleClassNode(decoratorNode) {
|
|
233
|
+
const parent = decoratorNode.parent;
|
|
234
|
+
if (parent?.type === "ClassDeclaration" || parent?.type === "ClassExpression") return parent;
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
function getStaticForRootMethod(classNode) {
|
|
238
|
+
if (!classNode?.body?.body) return null;
|
|
239
|
+
return classNode.body.body.find((element) => element.type === "MethodDefinition" && !!element.static && getPropertyName(element.key) === "forRoot") ?? null;
|
|
240
|
+
}
|
|
241
|
+
const avoidNgModules = defineRule({
|
|
242
|
+
meta: {
|
|
243
|
+
type: "suggestion",
|
|
244
|
+
docs: {
|
|
245
|
+
description: "Avoid NgModules in favor of standalone APIs and provider functions in modern Angular.",
|
|
246
|
+
recommended: true
|
|
247
|
+
},
|
|
248
|
+
schema: [{
|
|
249
|
+
type: "object",
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
properties: {
|
|
252
|
+
allowForGrouping: {
|
|
253
|
+
type: "boolean",
|
|
254
|
+
default: DEFAULT_ALLOW_FOR_GROUPING
|
|
255
|
+
},
|
|
256
|
+
allowForProviding: {
|
|
257
|
+
type: "boolean",
|
|
258
|
+
default: DEFAULT_ALLOW_FOR_PROVIDING
|
|
259
|
+
},
|
|
260
|
+
allowForRouting: {
|
|
261
|
+
type: "boolean",
|
|
262
|
+
default: DEFAULT_ALLOW_FOR_ROUTING
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}],
|
|
266
|
+
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
|
+
avoidRouterForChild: "Avoid RouterModule.forChild(routes). Prefer provideRouter(...) and lazy route entries such as loadComponent: () => import('./components/auth/login-page')."
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
createOnce(context) {
|
|
273
|
+
return { Decorator(node) {
|
|
274
|
+
const options = context.options[0] ?? {};
|
|
275
|
+
const allowForGrouping = options.allowForGrouping ?? DEFAULT_ALLOW_FOR_GROUPING;
|
|
276
|
+
const allowForProviding = options.allowForProviding ?? DEFAULT_ALLOW_FOR_PROVIDING;
|
|
277
|
+
const allowForRouting = options.allowForRouting ?? DEFAULT_ALLOW_FOR_ROUTING;
|
|
278
|
+
const metadata = getNgModuleMetadata(node);
|
|
279
|
+
if (!metadata) return;
|
|
280
|
+
const importsElements = getArrayElementsFromProperty(metadata, "imports");
|
|
281
|
+
const exportsElements = getArrayElementsFromProperty(metadata, "exports");
|
|
282
|
+
if (!allowForGrouping && (importsElements.length > 0 || exportsElements.length > 0)) context.report({
|
|
283
|
+
node,
|
|
284
|
+
messageId: "avoidModuleImportsExports"
|
|
285
|
+
});
|
|
286
|
+
const importCalls = importsElements.filter((element) => element.type === "CallExpression");
|
|
287
|
+
if (!allowForProviding) {
|
|
288
|
+
for (const call of importCalls) {
|
|
289
|
+
if (!isMemberCall(call, "forRoot")) continue;
|
|
290
|
+
context.report({
|
|
291
|
+
node: call,
|
|
292
|
+
messageId: "avoidForRoot"
|
|
293
|
+
});
|
|
294
|
+
}
|
|
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
|
+
}
|
|
308
|
+
} };
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/rules/avoid-writing-signals-in-reactive-context/index.ts
|
|
313
|
+
const SIGNAL_WRITE_METHODS = new Set([
|
|
314
|
+
"set",
|
|
315
|
+
"update",
|
|
316
|
+
"mutate"
|
|
317
|
+
]);
|
|
318
|
+
const KNOWN_SIGNAL_CREATION_FUNCTIONS = new Set([
|
|
319
|
+
"signal",
|
|
320
|
+
"model",
|
|
321
|
+
"linkedSignal"
|
|
322
|
+
]);
|
|
323
|
+
const LINKED_SIGNAL_CREATOR_NAME = "linkedSignal";
|
|
324
|
+
const COMPUTED_CREATOR_NAME = "computed";
|
|
325
|
+
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;
|
|
328
|
+
}
|
|
329
|
+
function isSignalCreatorCall(node, signalCreatorNames, angularNamespaces) {
|
|
330
|
+
if (node?.type !== "CallExpression") return false;
|
|
331
|
+
const callee = node.callee;
|
|
332
|
+
return callee?.type === "Identifier" && signalCreatorNames.has(callee.name) || [...KNOWN_SIGNAL_CREATION_FUNCTIONS].some((name) => isAngularCoreNamespaceMember(callee, angularNamespaces, name));
|
|
333
|
+
}
|
|
334
|
+
function isEffectCall(node, effectNames, angularNamespaces) {
|
|
335
|
+
const callee = node.callee;
|
|
336
|
+
return callee?.type === "Identifier" && effectNames.has(callee.name) || isAngularCoreNamespaceMember(callee, angularNamespaces, "effect");
|
|
337
|
+
}
|
|
338
|
+
function isReactiveCreatorCall(node, creatorNames, angularNamespaces, angularMemberName) {
|
|
339
|
+
const callee = node.callee;
|
|
340
|
+
return callee?.type === "Identifier" && creatorNames.has(callee.name) || isAngularCoreNamespaceMember(callee, angularNamespaces, angularMemberName);
|
|
341
|
+
}
|
|
342
|
+
function isKnownSignalObject(objectNode, signalVariables, classSignalProperties) {
|
|
343
|
+
if (!objectNode) return false;
|
|
344
|
+
if (objectNode.type === "Identifier") return signalVariables.has(objectNode.name);
|
|
345
|
+
return objectNode.type === "MemberExpression" && objectNode.object?.type === "ThisExpression" && objectNode.property?.type === "Identifier" && classSignalProperties.has(objectNode.property.name);
|
|
346
|
+
}
|
|
347
|
+
function visitNodes$1(node, visitor) {
|
|
348
|
+
if (!node) return;
|
|
349
|
+
if (Array.isArray(node)) {
|
|
350
|
+
for (const item of node) visitNodes$1(item, visitor);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
visitor(node);
|
|
354
|
+
for (const [key, value] of Object.entries(node)) {
|
|
355
|
+
if (key === "parent") continue;
|
|
356
|
+
if (!value || typeof value !== "object") continue;
|
|
357
|
+
visitNodes$1(value, visitor);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const avoidWritingSignalsInReactiveContext = defineRule({
|
|
361
|
+
meta: {
|
|
362
|
+
type: "problem",
|
|
363
|
+
docs: {
|
|
364
|
+
description: "Avoid writing to signals inside reactive callbacks such as effect(), computed(), and linkedSignal().",
|
|
365
|
+
recommended: true
|
|
366
|
+
},
|
|
367
|
+
schema: [{
|
|
368
|
+
type: "object",
|
|
369
|
+
additionalProperties: false,
|
|
370
|
+
properties: {
|
|
371
|
+
allowEffects: {
|
|
372
|
+
type: "boolean",
|
|
373
|
+
default: false
|
|
374
|
+
},
|
|
375
|
+
allowComputedAndLinkedSignals: {
|
|
376
|
+
type: "boolean",
|
|
377
|
+
default: false
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}],
|
|
381
|
+
messages: { avoidSignalWriteInReactiveContext: "Avoid setting signal values inside {{contextName}}; move writes outside reactive derivations and effects." }
|
|
382
|
+
},
|
|
383
|
+
createOnce(context) {
|
|
384
|
+
const effectNames = /* @__PURE__ */ new Set();
|
|
385
|
+
const computedNames = /* @__PURE__ */ new Set();
|
|
386
|
+
const linkedSignalNames = /* @__PURE__ */ new Set();
|
|
387
|
+
const signalCreatorNames = /* @__PURE__ */ new Set();
|
|
388
|
+
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
389
|
+
const signalVariables = /* @__PURE__ */ new Set();
|
|
390
|
+
const classSignalProperties = /* @__PURE__ */ new Set();
|
|
391
|
+
return {
|
|
392
|
+
before() {
|
|
393
|
+
effectNames.clear();
|
|
394
|
+
computedNames.clear();
|
|
395
|
+
linkedSignalNames.clear();
|
|
396
|
+
signalCreatorNames.clear();
|
|
397
|
+
angularNamespaces.clear();
|
|
398
|
+
signalVariables.clear();
|
|
399
|
+
classSignalProperties.clear();
|
|
400
|
+
},
|
|
401
|
+
ImportDeclaration(node) {
|
|
402
|
+
if (node.source?.value !== "@angular/core") return;
|
|
403
|
+
for (const specifier of node.specifiers ?? []) {
|
|
404
|
+
if (specifier.type === "ImportSpecifier") {
|
|
405
|
+
const importedName = getPropertyName(specifier.imported);
|
|
406
|
+
if (importedName === EFFECT_CREATOR_NAME) effectNames.add(specifier.local.name);
|
|
407
|
+
if (importedName === COMPUTED_CREATOR_NAME) computedNames.add(specifier.local.name);
|
|
408
|
+
if (importedName === LINKED_SIGNAL_CREATOR_NAME) linkedSignalNames.add(specifier.local.name);
|
|
409
|
+
if (importedName && KNOWN_SIGNAL_CREATION_FUNCTIONS.has(importedName)) signalCreatorNames.add(specifier.local.name);
|
|
410
|
+
}
|
|
411
|
+
if (specifier.type === "ImportNamespaceSpecifier") angularNamespaces.add(specifier.local.name);
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
VariableDeclarator(node) {
|
|
415
|
+
const declarator = node;
|
|
416
|
+
if (declarator.id?.type !== "Identifier") return;
|
|
417
|
+
if (!isSignalCreatorCall(declarator.init, signalCreatorNames, angularNamespaces)) return;
|
|
418
|
+
signalVariables.add(declarator.id.name);
|
|
419
|
+
},
|
|
420
|
+
"PropertyDefinition, FieldDefinition, AccessorProperty"(node) {
|
|
421
|
+
const property = node;
|
|
422
|
+
if (property.key?.type !== "Identifier") return;
|
|
423
|
+
if (!isSignalCreatorCall(property.value, signalCreatorNames, angularNamespaces)) return;
|
|
424
|
+
classSignalProperties.add(property.key.name);
|
|
425
|
+
},
|
|
426
|
+
CallExpression(node) {
|
|
427
|
+
const options = context.options?.[0] ?? {};
|
|
428
|
+
const allowEffects = options.allowEffects ?? false;
|
|
429
|
+
const allowComputedAndLinkedSignals = options.allowComputedAndLinkedSignals ?? false;
|
|
430
|
+
const callNode = node;
|
|
431
|
+
const callbackCandidates = [];
|
|
432
|
+
if (!allowEffects && isEffectCall(callNode, effectNames, angularNamespaces)) {
|
|
433
|
+
const callback = callNode.arguments?.[0];
|
|
434
|
+
if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") callbackCandidates.push({
|
|
435
|
+
callback,
|
|
436
|
+
contextName: "effect()"
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(callNode, computedNames, angularNamespaces, COMPUTED_CREATOR_NAME)) {
|
|
440
|
+
const callback = callNode.arguments?.[0];
|
|
441
|
+
if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") callbackCandidates.push({
|
|
442
|
+
callback,
|
|
443
|
+
contextName: "computed()"
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (!allowComputedAndLinkedSignals && isReactiveCreatorCall(callNode, linkedSignalNames, angularNamespaces, LINKED_SIGNAL_CREATOR_NAME)) for (const argumentNode of callNode.arguments ?? []) {
|
|
447
|
+
const argument = argumentNode;
|
|
448
|
+
if (argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression") {
|
|
449
|
+
callbackCandidates.push({
|
|
450
|
+
callback: argument,
|
|
451
|
+
contextName: "linkedSignal()"
|
|
452
|
+
});
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (argument.type !== "ObjectExpression") continue;
|
|
456
|
+
for (const propertyNode of argument.properties ?? []) {
|
|
457
|
+
const property = propertyNode;
|
|
458
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
459
|
+
if (getPropertyName(property.key) !== "computation") continue;
|
|
460
|
+
if (property.value?.type !== "ArrowFunctionExpression" && property.value?.type !== "FunctionExpression") continue;
|
|
461
|
+
callbackCandidates.push({
|
|
462
|
+
callback: property.value,
|
|
463
|
+
contextName: "linkedSignal().computation"
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
for (const { callback, contextName } of callbackCandidates) visitNodes$1(callback.body, (current) => {
|
|
468
|
+
if (current.type !== "CallExpression") return;
|
|
469
|
+
const callee = current.callee;
|
|
470
|
+
if (callee?.type !== "MemberExpression") return;
|
|
471
|
+
const methodName = getPropertyName(callee.property);
|
|
472
|
+
if (!methodName || !SIGNAL_WRITE_METHODS.has(methodName)) return;
|
|
473
|
+
if (!isKnownSignalObject(callee.object, signalVariables, classSignalProperties)) return;
|
|
474
|
+
context.report({
|
|
475
|
+
node: callee.property ?? callee,
|
|
476
|
+
messageId: "avoidSignalWriteInReactiveContext",
|
|
477
|
+
data: { contextName }
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
"Program:exit"() {
|
|
482
|
+
effectNames.clear();
|
|
483
|
+
computedNames.clear();
|
|
484
|
+
linkedSignalNames.clear();
|
|
485
|
+
signalCreatorNames.clear();
|
|
486
|
+
angularNamespaces.clear();
|
|
487
|
+
signalVariables.clear();
|
|
488
|
+
classSignalProperties.clear();
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/rules/component-class-matches-filename/index.ts
|
|
495
|
+
function toPascalCase(raw) {
|
|
496
|
+
return raw.split(/[-_\s.]+/u).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
497
|
+
}
|
|
498
|
+
function getExpectedComponentClassName(filename) {
|
|
499
|
+
const base = filename.split(/[/\\]/u).at(-1) ?? "";
|
|
500
|
+
const match = /^(.*)\.component\.ts$/u.exec(base);
|
|
501
|
+
if (!match) return null;
|
|
502
|
+
const stem = match[1];
|
|
503
|
+
const pascal = toPascalCase(stem);
|
|
504
|
+
return pascal ? `${pascal}Component` : null;
|
|
505
|
+
}
|
|
506
|
+
const componentClassMatchesFilename = defineRule({
|
|
507
|
+
meta: {
|
|
508
|
+
type: "suggestion",
|
|
509
|
+
docs: {
|
|
510
|
+
description: "Require the component class name to match its *.component.ts filename.",
|
|
511
|
+
recommended: true
|
|
512
|
+
},
|
|
513
|
+
schema: [],
|
|
514
|
+
messages: { classNameMismatch: "Component class name should be '{{expectedName}}' to match the filename '{{filename}}'." }
|
|
515
|
+
},
|
|
516
|
+
createOnce(context) {
|
|
517
|
+
const classDeclarations = [];
|
|
518
|
+
return {
|
|
519
|
+
before() {
|
|
520
|
+
classDeclarations.length = 0;
|
|
521
|
+
},
|
|
522
|
+
ClassDeclaration(node) {
|
|
523
|
+
classDeclarations.push(node);
|
|
524
|
+
},
|
|
525
|
+
after() {
|
|
526
|
+
const filename = context.filename ?? "";
|
|
527
|
+
const expectedName = getExpectedComponentClassName(filename);
|
|
528
|
+
if (!expectedName) return;
|
|
529
|
+
const baseFilename = filename.split(/[/\\]/u).at(-1) ?? filename;
|
|
530
|
+
const classNode = classDeclarations.find((candidate) => candidate.parent?.type === "ExportNamedDeclaration" || candidate.parent?.type === "ExportDefaultDeclaration") ?? null ?? classDeclarations[0];
|
|
531
|
+
if (!classNode?.id?.name) return;
|
|
532
|
+
if (classNode.id.name === expectedName) return;
|
|
533
|
+
context.report({
|
|
534
|
+
node: classNode.id,
|
|
535
|
+
messageId: "classNameMismatch",
|
|
536
|
+
data: {
|
|
537
|
+
expectedName,
|
|
538
|
+
filename: baseFilename
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region src/rules/prefer-load-component-over-load-children/index.ts
|
|
547
|
+
function isRoutesTypeAnnotation(node) {
|
|
548
|
+
const typeAnnotation = node?.typeAnnotation;
|
|
549
|
+
if (!typeAnnotation || typeAnnotation.type !== "TSTypeAnnotation") return false;
|
|
550
|
+
const annotation = typeAnnotation.typeAnnotation;
|
|
551
|
+
if (!annotation || annotation.type !== "TSTypeReference") return false;
|
|
552
|
+
return getPropertyName(annotation.typeName) === "Routes";
|
|
553
|
+
}
|
|
554
|
+
function isExportedConstDeclarator(node) {
|
|
555
|
+
if (node.type !== "VariableDeclarator") return false;
|
|
556
|
+
const declaration = node.parent;
|
|
557
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") return false;
|
|
558
|
+
return declaration.parent?.type === "ExportNamedDeclaration";
|
|
559
|
+
}
|
|
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
|
+
const preferLoadComponentOverLoadChildren = defineRule({
|
|
574
|
+
meta: {
|
|
575
|
+
type: "problem",
|
|
576
|
+
docs: {
|
|
577
|
+
description: "Prefer lazy loading route components with loadComponent and disallow loadChildren in exported Routes arrays.",
|
|
578
|
+
recommended: true
|
|
579
|
+
},
|
|
580
|
+
schema: [],
|
|
581
|
+
messages: { avoidLoadChildren: "Avoid loadChildren in Routes arrays; prefer loadComponent for lazy-loading standalone route components." }
|
|
582
|
+
},
|
|
583
|
+
createOnce(context) {
|
|
584
|
+
return { VariableDeclarator(node) {
|
|
585
|
+
const declarator = node;
|
|
586
|
+
if (!isExportedConstDeclarator(declarator)) return;
|
|
587
|
+
if (declarator.id?.type !== "Identifier") return;
|
|
588
|
+
if (!isRoutesTypeAnnotation(declarator.id)) return;
|
|
589
|
+
if (declarator.init?.type !== "ArrayExpression") return;
|
|
590
|
+
visitNodes(declarator.init.elements, (current) => {
|
|
591
|
+
if (current.type !== "Property" || current.computed) return;
|
|
592
|
+
if (getPropertyName(current.key) !== "loadChildren") return;
|
|
593
|
+
context.report({
|
|
594
|
+
node: current.key ?? current,
|
|
595
|
+
messageId: "avoidLoadChildren"
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
} };
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/rules/prefer-private-elements/index.ts
|
|
603
|
+
function getPrivateTarget(element) {
|
|
604
|
+
if (element.type !== "PropertyDefinition" && element.type !== "FieldDefinition" && element.type !== "AccessorProperty" && element.type !== "MethodDefinition") return null;
|
|
605
|
+
if (element.accessibility !== "private") return null;
|
|
606
|
+
if (element.computed) return null;
|
|
607
|
+
if (element.type === "MethodDefinition" && element.kind === "constructor") return null;
|
|
608
|
+
const name = getPropertyName(element.key);
|
|
609
|
+
if (!name || element.key?.type !== "Identifier") return null;
|
|
610
|
+
return {
|
|
611
|
+
element,
|
|
612
|
+
nameNode: element.key,
|
|
613
|
+
name,
|
|
614
|
+
isStatic: !!element.static,
|
|
615
|
+
accessorKind: element.type === "MethodDefinition" && (element.kind === "get" || element.kind === "set") ? element.kind : null
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function getPrivateModifierRange(context, element, nameNode) {
|
|
619
|
+
const elementRange = getRange(element);
|
|
620
|
+
const nameRange = getRange(nameNode);
|
|
621
|
+
if (!elementRange || !nameRange) return null;
|
|
622
|
+
const text = context.sourceCode.text.slice(elementRange[0], nameRange[0]);
|
|
623
|
+
const match = /\bprivate\b\s*/u.exec(text);
|
|
624
|
+
return match ? [elementRange[0] + match.index, elementRange[0] + match.index + match[0].length] : null;
|
|
625
|
+
}
|
|
626
|
+
function sameStaticName(target, candidate) {
|
|
627
|
+
return !!candidate.static === target.isStatic && getPropertyName(candidate.key) === target.name;
|
|
628
|
+
}
|
|
629
|
+
function collectDeclarationEdits(context, classNode, target) {
|
|
630
|
+
const privateRange = getPrivateModifierRange(context, target.element, target.nameNode);
|
|
631
|
+
const nameRange = getRange(target.nameNode);
|
|
632
|
+
if (!privateRange || !nameRange) return null;
|
|
633
|
+
const declarations = [{
|
|
634
|
+
privateRange,
|
|
635
|
+
nameRange,
|
|
636
|
+
name: target.name
|
|
637
|
+
}];
|
|
638
|
+
for (const element of classNode.body?.body ?? []) {
|
|
639
|
+
if (element === target.element || !sameStaticName(target, element)) continue;
|
|
640
|
+
const other = getPrivateTarget(element);
|
|
641
|
+
if (!(target.accessorKind && other?.accessorKind && target.accessorKind !== other.accessorKind)) return null;
|
|
642
|
+
const otherPrivateRange = getPrivateModifierRange(context, other.element, other.nameNode);
|
|
643
|
+
const otherNameRange = getRange(other.nameNode);
|
|
644
|
+
if (!otherPrivateRange || !otherNameRange) return null;
|
|
645
|
+
declarations.push({
|
|
646
|
+
privateRange: otherPrivateRange,
|
|
647
|
+
nameRange: otherNameRange,
|
|
648
|
+
name: other.name
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return declarations;
|
|
652
|
+
}
|
|
653
|
+
function isThisOrClassReceiver(node, target, className) {
|
|
654
|
+
if (!node) return false;
|
|
655
|
+
if (target.isStatic) return node.type === "Identifier" && !!className && node.name === className;
|
|
656
|
+
return node.type === "ThisExpression";
|
|
657
|
+
}
|
|
658
|
+
function collectReferenceEdits(classNode, target, className) {
|
|
659
|
+
const references = [];
|
|
660
|
+
let unsupported = false;
|
|
661
|
+
function visit(node, functionDepth) {
|
|
662
|
+
if (!node || unsupported) return;
|
|
663
|
+
if (Array.isArray(node)) {
|
|
664
|
+
for (const item of node) visit(item, functionDepth);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (node !== classNode && (node.type === "ClassDeclaration" || node.type === "ClassExpression")) return;
|
|
668
|
+
const nextFunctionDepth = node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" ? functionDepth + 1 : functionDepth;
|
|
669
|
+
if (node.type === "MemberExpression" && !node.computed && getPropertyName(node.property) === target.name) {
|
|
670
|
+
if (nextFunctionDepth > 1 || !isThisOrClassReceiver(node.object, target, className)) {
|
|
671
|
+
unsupported = true;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const range = getRange(node.property);
|
|
675
|
+
if (!range) {
|
|
676
|
+
unsupported = true;
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
references.push({
|
|
680
|
+
range,
|
|
681
|
+
name: target.name
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
if (node.type === "MemberExpression" && node.computed && getPropertyName(node.property) === target.name) {
|
|
685
|
+
unsupported = true;
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
for (const [key, value] of Object.entries(node)) {
|
|
689
|
+
if (key === "parent") continue;
|
|
690
|
+
if (!value || typeof value !== "object") continue;
|
|
691
|
+
visit(value, nextFunctionDepth);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
visit(classNode, 0);
|
|
695
|
+
return unsupported ? null : references;
|
|
696
|
+
}
|
|
697
|
+
function buildFixPlan(context, classNode, target) {
|
|
698
|
+
const declarations = collectDeclarationEdits(context, classNode, target);
|
|
699
|
+
if (!declarations) return null;
|
|
700
|
+
const references = collectReferenceEdits(classNode, target, classNode.id?.type === "Identifier" ? classNode.id.name : null);
|
|
701
|
+
if (!references) return null;
|
|
702
|
+
return {
|
|
703
|
+
declarations,
|
|
704
|
+
references
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function getEnclosingClass(context, node) {
|
|
708
|
+
const ancestors = context.sourceCode.getAncestors(node);
|
|
709
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
710
|
+
const ancestor = ancestors[index];
|
|
711
|
+
if (ancestor?.type === "ClassDeclaration" || ancestor?.type === "ClassExpression") return ancestor;
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
function shouldSkipDuplicateAccessorReport(classNode, target) {
|
|
716
|
+
if (!target.accessorKind) return false;
|
|
717
|
+
return (classNode.body?.body ?? []).some((element) => {
|
|
718
|
+
const other = getPrivateTarget(element);
|
|
719
|
+
return other && other !== target && other.name === target.name && other.isStatic === target.isStatic && other.accessorKind && other.accessorKind !== target.accessorKind && (getRange(other.element)?.[0] ?? Number.POSITIVE_INFINITY) < (getRange(target.element)?.[0] ?? 0);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
function applyFixPlan(fixer, plan) {
|
|
723
|
+
const fixes = [];
|
|
724
|
+
for (const declaration of plan.declarations) {
|
|
725
|
+
fixes.push(fixer.removeRange(declaration.privateRange));
|
|
726
|
+
fixes.push(fixer.replaceTextRange(declaration.nameRange, `#${declaration.name}`));
|
|
727
|
+
}
|
|
728
|
+
for (const reference of plan.references) fixes.push(fixer.replaceTextRange(reference.range, `#${reference.name}`));
|
|
729
|
+
return fixes;
|
|
730
|
+
}
|
|
731
|
+
const preferPrivateElements = defineRule({
|
|
732
|
+
meta: {
|
|
733
|
+
type: "suggestion",
|
|
734
|
+
docs: {
|
|
735
|
+
description: "Prefer ECMAScript private elements over TypeScript private members.",
|
|
736
|
+
recommended: true
|
|
737
|
+
},
|
|
738
|
+
fixable: "code",
|
|
739
|
+
schema: [],
|
|
740
|
+
messages: { preferPrivateElements: "TypeScript private member {{name}} should be an ECMAScript private element." }
|
|
741
|
+
},
|
|
742
|
+
createOnce(context) {
|
|
743
|
+
return { "PropertyDefinition, FieldDefinition, AccessorProperty, MethodDefinition"(node) {
|
|
744
|
+
const target = getPrivateTarget(node);
|
|
745
|
+
if (!target) return;
|
|
746
|
+
const classNode = getEnclosingClass(context, node);
|
|
747
|
+
if (!classNode || shouldSkipDuplicateAccessorReport(classNode, target)) return;
|
|
748
|
+
const fixPlan = buildFixPlan(context, classNode, target);
|
|
749
|
+
context.report({
|
|
750
|
+
node: target.nameNode,
|
|
751
|
+
messageId: "preferPrivateElements",
|
|
752
|
+
data: { name: target.name },
|
|
753
|
+
fix: fixPlan ? (fixer) => applyFixPlan(fixer, fixPlan) : void 0
|
|
754
|
+
});
|
|
755
|
+
} };
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region src/rules/prefer-style-url/index.ts
|
|
760
|
+
function isComponentDecoratorCall(node) {
|
|
761
|
+
const callee = node.callee;
|
|
762
|
+
if (callee?.type === "Identifier") return callee.name === "Component";
|
|
763
|
+
return callee?.type === "MemberExpression" && getPropertyName(callee.property) === "Component";
|
|
764
|
+
}
|
|
765
|
+
function isSingleStyleFileNode(node) {
|
|
766
|
+
if (!node) return false;
|
|
767
|
+
if (node.type === "Literal" || node.type === "StringLiteral") return typeof node.value === "string";
|
|
768
|
+
return node.type === "TemplateLiteral" && node.expressions?.length === 0;
|
|
769
|
+
}
|
|
770
|
+
const preferStyleUrl = defineRule({
|
|
771
|
+
meta: {
|
|
772
|
+
type: "suggestion",
|
|
773
|
+
docs: {
|
|
774
|
+
description: "Prefer Angular component styleUrl when there is only one style file.",
|
|
775
|
+
recommended: true
|
|
776
|
+
},
|
|
777
|
+
fixable: "code",
|
|
778
|
+
schema: [],
|
|
779
|
+
messages: { preferStyleUrl: "Use styleUrl instead of styleUrls when a component has one style file." }
|
|
780
|
+
},
|
|
781
|
+
createOnce(context) {
|
|
782
|
+
return { CallExpression(node) {
|
|
783
|
+
const call = node;
|
|
784
|
+
if (!isComponentDecoratorCall(call)) return;
|
|
785
|
+
const metadata = call.arguments?.[0];
|
|
786
|
+
if (metadata?.type !== "ObjectExpression") return;
|
|
787
|
+
for (const property of metadata.properties ?? []) {
|
|
788
|
+
if (property.type !== "Property") continue;
|
|
789
|
+
if (property.computed) continue;
|
|
790
|
+
if (getPropertyName(property.key) !== "styleUrls") continue;
|
|
791
|
+
if (property.value?.type !== "ArrayExpression") continue;
|
|
792
|
+
const elements = property.value.elements?.filter(Boolean) ?? [];
|
|
793
|
+
if (elements.length !== 1) continue;
|
|
794
|
+
const styleFile = elements[0];
|
|
795
|
+
if (!isSingleStyleFileNode(styleFile)) continue;
|
|
796
|
+
const keyRange = getRange(property.key);
|
|
797
|
+
const valueRange = getRange(property.value);
|
|
798
|
+
const styleFileRange = getRange(styleFile);
|
|
799
|
+
context.report({
|
|
800
|
+
node: property.key,
|
|
801
|
+
messageId: "preferStyleUrl",
|
|
802
|
+
fix: keyRange && valueRange && styleFileRange ? (fixer) => [fixer.replaceTextRange(keyRange, "styleUrl"), fixer.replaceTextRange(valueRange, context.sourceCode.text.slice(styleFileRange[0], styleFileRange[1]))] : void 0
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
} };
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/rules/restrict-injectable-provided-in/index.ts
|
|
810
|
+
const ALLOWED_PROVIDED_IN_VALUES = new Set(["root", "platform"]);
|
|
811
|
+
function getInjectableMetadata(node) {
|
|
812
|
+
if (node.type !== "Decorator") return null;
|
|
813
|
+
if (getDecoratorName(node) !== "Injectable") return null;
|
|
814
|
+
const expression = node.expression;
|
|
815
|
+
if (expression?.type !== "CallExpression") return null;
|
|
816
|
+
const metadata = expression.arguments?.[0];
|
|
817
|
+
return metadata?.type === "ObjectExpression" ? metadata : null;
|
|
818
|
+
}
|
|
819
|
+
function getProvidedInProperty(metadata) {
|
|
820
|
+
return (metadata.properties ?? []).find((candidate) => candidate.type === "Property" && !candidate.computed && getPropertyName(candidate.key) === "providedIn") ?? null;
|
|
821
|
+
}
|
|
822
|
+
function isAllowedProvidedInValue(node) {
|
|
823
|
+
if (!node) return false;
|
|
824
|
+
if (node.type === "Literal" || node.type === "StringLiteral") return typeof node.value === "string" && ALLOWED_PROVIDED_IN_VALUES.has(node.value);
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
const restrictInjectableProvidedIn = defineRule({
|
|
828
|
+
meta: {
|
|
829
|
+
type: "suggestion",
|
|
830
|
+
docs: {
|
|
831
|
+
description: "Require @Injectable providedIn to be only 'root' or 'platform'.",
|
|
832
|
+
recommended: true
|
|
833
|
+
},
|
|
834
|
+
schema: [],
|
|
835
|
+
messages: { disallowedProvidedIn: "@Injectable providedIn should be 'root' or 'platform', not {{actual}}." }
|
|
836
|
+
},
|
|
837
|
+
createOnce(context) {
|
|
838
|
+
return { Decorator(node) {
|
|
839
|
+
const metadata = getInjectableMetadata(node);
|
|
840
|
+
if (!metadata) return;
|
|
841
|
+
const providedInProperty = getProvidedInProperty(metadata);
|
|
842
|
+
if (!providedInProperty) return;
|
|
843
|
+
const value = providedInProperty.value;
|
|
844
|
+
if (isAllowedProvidedInValue(value)) return;
|
|
845
|
+
const actual = context.sourceCode.text.slice(value?.range?.[0] ?? providedInProperty.range?.[0] ?? 0, value?.range?.[1] ?? providedInProperty.range?.[1] ?? 0);
|
|
846
|
+
context.report({
|
|
847
|
+
node: value ?? providedInProperty,
|
|
848
|
+
messageId: "disallowedProvidedIn",
|
|
849
|
+
data: { actual: actual || "this value" }
|
|
850
|
+
});
|
|
851
|
+
} };
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
//#endregion
|
|
855
|
+
//#region src/rules/rules-of-inject/index.ts
|
|
856
|
+
const DEFAULT_ALLOWED_FUNCTION_NAMES = [];
|
|
857
|
+
const DEFAULT_INJECT_FUNCTION_PREFIXES = ["injext"];
|
|
858
|
+
const DEFAULT_INJECT_FUNCTION_SUFFIXES = ["Guard"];
|
|
859
|
+
const DEFAULT_RUNS_IN_INJECTION_CONTEXT = [];
|
|
860
|
+
const ROUTER_CONTEXT_PROPERTY_NAMES = new Set([
|
|
861
|
+
"loadChildren",
|
|
862
|
+
"loadComponent",
|
|
863
|
+
"canActivate",
|
|
864
|
+
"canActivateChild",
|
|
865
|
+
"canDeactivate",
|
|
866
|
+
"canMatch",
|
|
867
|
+
"canLoad",
|
|
868
|
+
"redirectTo",
|
|
869
|
+
"resolve",
|
|
870
|
+
"title"
|
|
871
|
+
]);
|
|
872
|
+
const ANGULAR_CLASS_DECORATOR_NAMES = new Set([
|
|
873
|
+
"Component",
|
|
874
|
+
"Directive",
|
|
875
|
+
"Injectable",
|
|
876
|
+
"Service",
|
|
877
|
+
"Pipe",
|
|
878
|
+
"NgModule"
|
|
879
|
+
]);
|
|
880
|
+
const INJECTION_CONTEXT_RUNNER_NAMES = new Set(["runInInjectionContext", "runInContext"]);
|
|
881
|
+
const INJECTION_CONTEXT_FUNCTION_TYPE_NAMES = new Set([
|
|
882
|
+
"CanActivateFn",
|
|
883
|
+
"CanActivateChildFn",
|
|
884
|
+
"CanDeactivateFn",
|
|
885
|
+
"CanLoadFn",
|
|
886
|
+
"CanMatchFn",
|
|
887
|
+
"HttpInterceptorFn",
|
|
888
|
+
"RedirectFunction",
|
|
889
|
+
"ResolveFn"
|
|
890
|
+
]);
|
|
891
|
+
const FUNCTION_TYPES = new Set([
|
|
892
|
+
"ArrowFunctionExpression",
|
|
893
|
+
"FunctionDeclaration",
|
|
894
|
+
"FunctionExpression"
|
|
895
|
+
]);
|
|
896
|
+
const CLASS_FIELD_TYPES = new Set([
|
|
897
|
+
"AccessorProperty",
|
|
898
|
+
"FieldDefinition",
|
|
899
|
+
"PropertyDefinition"
|
|
900
|
+
]);
|
|
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
|
+
function isFunction(node) {
|
|
913
|
+
return !!node && FUNCTION_TYPES.has(node.type);
|
|
914
|
+
}
|
|
915
|
+
function getAncestors(context, node) {
|
|
916
|
+
return context.sourceCode.getAncestors(node);
|
|
917
|
+
}
|
|
918
|
+
function isPropertyValueFunction(functionNode, propertyNames) {
|
|
919
|
+
const parent = functionNode.parent;
|
|
920
|
+
return parent?.type === "Property" && parent.value === functionNode && propertyNames.has(getPropertyName(parent.key) ?? "");
|
|
921
|
+
}
|
|
922
|
+
function isRunInInjectionContextCallback(functionNode) {
|
|
923
|
+
const parent = functionNode.parent;
|
|
924
|
+
if (parent?.type !== "CallExpression") return false;
|
|
925
|
+
if (!parent.arguments?.includes(functionNode)) return false;
|
|
926
|
+
const callee = parent.callee;
|
|
927
|
+
if (callee?.type === "Identifier") return INJECTION_CONTEXT_RUNNER_NAMES.has(callee.name);
|
|
928
|
+
return callee?.type === "MemberExpression" && INJECTION_CONTEXT_RUNNER_NAMES.has(getPropertyName(callee.property) ?? "");
|
|
929
|
+
}
|
|
930
|
+
function isNestedInPropertyValue(functionNode, propertyNames) {
|
|
931
|
+
let current = functionNode.parent;
|
|
932
|
+
while (current) {
|
|
933
|
+
if (current.type === "Property" && propertyNames.has(getPropertyName(current.key) ?? "")) return true;
|
|
934
|
+
if (isFunction(current)) return false;
|
|
935
|
+
current = current.parent;
|
|
936
|
+
}
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
function getFunctionName(functionNode) {
|
|
940
|
+
const parent = skipTransparentExpressionParents(functionNode.parent);
|
|
941
|
+
if (functionNode.type === "FunctionDeclaration") return functionNode.id?.name ?? null;
|
|
942
|
+
if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") return parent.id.name;
|
|
943
|
+
if (parent?.type === "Property" && parent.value === functionNode) return getPropertyName(parent.key);
|
|
944
|
+
if (parent?.type === "MethodDefinition" && parent.value === functionNode) return getPropertyName(parent.key);
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
function skipTransparentExpressionParents(node) {
|
|
948
|
+
let current = node;
|
|
949
|
+
while (current?.type === "ParenthesizedExpression" || current?.type === "TSAsExpression" || current?.type === "TSSatisfiesExpression" || current?.type === "TSNonNullExpression") current = current.parent;
|
|
950
|
+
return current ?? null;
|
|
951
|
+
}
|
|
952
|
+
function getTransparentExpressionTypeName(functionNode) {
|
|
953
|
+
let current = functionNode.parent;
|
|
954
|
+
while (current?.type === "ParenthesizedExpression" || current?.type === "TSAsExpression" || current?.type === "TSSatisfiesExpression" || current?.type === "TSNonNullExpression") {
|
|
955
|
+
if (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression") {
|
|
956
|
+
const typeName = getTypeName(current.typeAnnotation);
|
|
957
|
+
if (typeName) return typeName;
|
|
958
|
+
}
|
|
959
|
+
current = current.parent;
|
|
960
|
+
}
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
function getFunctionContextTypeName(functionNode) {
|
|
964
|
+
const transparentTypeName = getTransparentExpressionTypeName(functionNode);
|
|
965
|
+
if (transparentTypeName) return transparentTypeName;
|
|
966
|
+
const parent = skipTransparentExpressionParents(functionNode.parent);
|
|
967
|
+
if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") return getTypeName(parent.id.typeAnnotation?.typeAnnotation);
|
|
968
|
+
if (parent?.type === "Property" && parent.value === functionNode) return getTypeName(parent.typeAnnotation?.typeAnnotation);
|
|
969
|
+
if (parent && CLASS_FIELD_TYPES.has(parent.type) && parent.value === functionNode) return getTypeName(parent.typeAnnotation?.typeAnnotation);
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
function isTypedInjectionContextFunction(functionNode) {
|
|
973
|
+
const typeName = getFunctionContextTypeName(functionNode);
|
|
974
|
+
if (!typeName) return false;
|
|
975
|
+
const unqualifiedTypeName = typeName.includes(".") ? typeName.split(".").at(-1) : typeName;
|
|
976
|
+
return INJECTION_CONTEXT_FUNCTION_TYPE_NAMES.has(unqualifiedTypeName ?? typeName);
|
|
977
|
+
}
|
|
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
|
+
function hasAwaitBeforeNodeInFunction(functionNode, node) {
|
|
984
|
+
const nodeStart = getNodeStart(node);
|
|
985
|
+
if (nodeStart === null) return false;
|
|
986
|
+
const targetStart = nodeStart;
|
|
987
|
+
function visit(current) {
|
|
988
|
+
if (!current) return false;
|
|
989
|
+
if (Array.isArray(current)) return current.some(visit);
|
|
990
|
+
if (current !== functionNode && isFunction(current)) return false;
|
|
991
|
+
if (current.type === "AwaitExpression") {
|
|
992
|
+
const awaitStart = getNodeStart(current);
|
|
993
|
+
return awaitStart !== null && awaitStart < targetStart;
|
|
994
|
+
}
|
|
995
|
+
for (const [key, value] of Object.entries(current)) {
|
|
996
|
+
if (key === "parent") continue;
|
|
997
|
+
if (!value || typeof value !== "object") continue;
|
|
998
|
+
if (visit(value)) return true;
|
|
999
|
+
}
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
return visit(functionNode.body);
|
|
1003
|
+
}
|
|
1004
|
+
function isConstructorFunction(functionNode) {
|
|
1005
|
+
return functionNode.parent?.type === "MethodDefinition" && functionNode.parent.kind === "constructor" && functionNode.parent.value === functionNode;
|
|
1006
|
+
}
|
|
1007
|
+
function isDirectClassFieldInitializer(ancestors, nearestFunction) {
|
|
1008
|
+
return !!ancestors.findLast((ancestor) => CLASS_FIELD_TYPES.has(ancestor.type)) && !nearestFunction;
|
|
1009
|
+
}
|
|
1010
|
+
function hasSupportedAngularClassDecorator(classNode) {
|
|
1011
|
+
if (!classNode) return false;
|
|
1012
|
+
if (!Array.isArray(classNode.decorators)) return false;
|
|
1013
|
+
return classNode.decorators.some((decorator) => ANGULAR_CLASS_DECORATOR_NAMES.has(getDecoratorName(decorator) ?? ""));
|
|
1014
|
+
}
|
|
1015
|
+
function isAllowedInjectionContext(context, node, allowedFunctionNames, injectFunctionPrefixes, injectFunctionSuffixes) {
|
|
1016
|
+
const ancestors = getAncestors(context, node);
|
|
1017
|
+
const nearestFunction = ancestors.findLast(isFunction);
|
|
1018
|
+
const enclosingClass = ancestors.findLast((ancestor) => ancestor.type === "ClassDeclaration" || ancestor.type === "ClassExpression");
|
|
1019
|
+
if (nearestFunction && hasAwaitBeforeNodeInFunction(nearestFunction, node)) return false;
|
|
1020
|
+
if (isDirectClassFieldInitializer(ancestors, nearestFunction) && hasSupportedAngularClassDecorator(enclosingClass)) return true;
|
|
1021
|
+
if (!nearestFunction) return false;
|
|
1022
|
+
if (isConstructorFunction(nearestFunction) && hasSupportedAngularClassDecorator(enclosingClass)) return true;
|
|
1023
|
+
if (isPropertyValueFunction(nearestFunction, new Set(["useFactory", "factory"]))) return true;
|
|
1024
|
+
if (isRunInInjectionContextCallback(nearestFunction)) return true;
|
|
1025
|
+
if (isTypedInjectionContextFunction(nearestFunction)) return true;
|
|
1026
|
+
if (isNestedInPropertyValue(nearestFunction, ROUTER_CONTEXT_PROPERTY_NAMES)) return true;
|
|
1027
|
+
const functionName = getFunctionName(nearestFunction);
|
|
1028
|
+
return functionName ? allowedFunctionNames.has(functionName) || injectFunctionPrefixes.some((prefix) => functionName.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => functionName.endsWith(suffix)) : false;
|
|
1029
|
+
}
|
|
1030
|
+
function isAngularInjectCall(node, injectNames, angularNamespaces, checkUnimportedInject) {
|
|
1031
|
+
const callee = node.callee;
|
|
1032
|
+
if (callee?.type === "Identifier") return injectNames.has(callee.name) || checkUnimportedInject && callee.name === "inject";
|
|
1033
|
+
return callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && angularNamespaces.has(callee.object.name) && getPropertyName(callee.property) === "inject";
|
|
1034
|
+
}
|
|
1035
|
+
function isInjectLikeHelperCall(node, injectNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) {
|
|
1036
|
+
const callee = node.callee;
|
|
1037
|
+
if (callee?.type !== "Identifier") return false;
|
|
1038
|
+
if (runsInInjectionContextFunctionNames.has(callee.name)) return true;
|
|
1039
|
+
return !injectNames.has(callee.name) && callee.name !== "inject" && (injectFunctionPrefixes.some((prefix) => callee.name.startsWith(prefix)) || injectFunctionSuffixes.some((suffix) => callee.name.endsWith(suffix)));
|
|
1040
|
+
}
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/index.ts
|
|
1043
|
+
const plugin = eslintCompatPlugin({
|
|
1044
|
+
meta: { name: "@benjavicente/lint-angular" },
|
|
1045
|
+
rules: {
|
|
1046
|
+
"avoid-explicit-injection-context": avoidExplicitInjectionContext,
|
|
1047
|
+
"avoid-ng-modules": avoidNgModules,
|
|
1048
|
+
"avoid-writing-signals-in-reactive-context": avoidWritingSignalsInReactiveContext,
|
|
1049
|
+
"class-member-order": classMemberOrder,
|
|
1050
|
+
"component-class-matches-filename": componentClassMatchesFilename,
|
|
1051
|
+
"prefer-load-component-over-load-children": preferLoadComponentOverLoadChildren,
|
|
1052
|
+
"prefer-private-elements": preferPrivateElements,
|
|
1053
|
+
"prefer-style-url": preferStyleUrl,
|
|
1054
|
+
"restrict-injectable-provided-in": restrictInjectableProvidedIn,
|
|
1055
|
+
"rules-of-inject": defineRule({
|
|
1056
|
+
meta: {
|
|
1057
|
+
type: "problem",
|
|
1058
|
+
docs: {
|
|
1059
|
+
description: "Require Angular inject() calls to appear only in known injection contexts.",
|
|
1060
|
+
recommended: true
|
|
1061
|
+
},
|
|
1062
|
+
schema: [{
|
|
1063
|
+
type: "object",
|
|
1064
|
+
additionalProperties: false,
|
|
1065
|
+
properties: {
|
|
1066
|
+
allowedFunctionNames: {
|
|
1067
|
+
type: "array",
|
|
1068
|
+
items: { type: "string" },
|
|
1069
|
+
default: DEFAULT_ALLOWED_FUNCTION_NAMES
|
|
1070
|
+
},
|
|
1071
|
+
checkUnimportedInject: {
|
|
1072
|
+
type: "boolean",
|
|
1073
|
+
default: false
|
|
1074
|
+
},
|
|
1075
|
+
injectFunctionPrefixes: {
|
|
1076
|
+
type: "array",
|
|
1077
|
+
items: { type: "string" },
|
|
1078
|
+
default: DEFAULT_INJECT_FUNCTION_PREFIXES
|
|
1079
|
+
},
|
|
1080
|
+
injectFunctionSuffixes: {
|
|
1081
|
+
type: "array",
|
|
1082
|
+
items: { type: "string" },
|
|
1083
|
+
default: DEFAULT_INJECT_FUNCTION_SUFFIXES
|
|
1084
|
+
},
|
|
1085
|
+
runsInInjectionContext: {
|
|
1086
|
+
type: "array",
|
|
1087
|
+
items: {
|
|
1088
|
+
type: "object",
|
|
1089
|
+
additionalProperties: false,
|
|
1090
|
+
required: ["from", "imports"],
|
|
1091
|
+
properties: {
|
|
1092
|
+
from: { type: "string" },
|
|
1093
|
+
imports: { anyOf: [{ const: "all" }, {
|
|
1094
|
+
type: "array",
|
|
1095
|
+
items: { type: "string" }
|
|
1096
|
+
}] }
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
default: DEFAULT_RUNS_IN_INJECTION_CONTEXT
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}],
|
|
1103
|
+
messages: { disallowedInject: "Angular inject() 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
|
+
},
|
|
1105
|
+
createOnce(context) {
|
|
1106
|
+
const injectNames = /* @__PURE__ */ new Set();
|
|
1107
|
+
const angularNamespaces = /* @__PURE__ */ new Set();
|
|
1108
|
+
const runsInInjectionContextFunctionNames = /* @__PURE__ */ new Set();
|
|
1109
|
+
let runsInInjectionContextRules = [];
|
|
1110
|
+
return {
|
|
1111
|
+
before() {
|
|
1112
|
+
injectNames.clear();
|
|
1113
|
+
angularNamespaces.clear();
|
|
1114
|
+
runsInInjectionContextFunctionNames.clear();
|
|
1115
|
+
runsInInjectionContextRules = (context.options[0] ?? {}).runsInInjectionContext ?? DEFAULT_RUNS_IN_INJECTION_CONTEXT;
|
|
1116
|
+
},
|
|
1117
|
+
ImportDeclaration(node) {
|
|
1118
|
+
const source = node.source?.value;
|
|
1119
|
+
const matchingRule = typeof source === "string" ? runsInInjectionContextRules.find((rule) => rule.from === source) : null;
|
|
1120
|
+
if (matchingRule) for (const specifier of node.specifiers ?? []) {
|
|
1121
|
+
if (specifier.type === "ImportSpecifier" && (matchingRule.imports === "all" || matchingRule.imports.includes(getPropertyName(specifier.imported) ?? ""))) runsInInjectionContextFunctionNames.add(specifier.local.name);
|
|
1122
|
+
if ((specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") && matchingRule.imports === "all") runsInInjectionContextFunctionNames.add(specifier.local.name);
|
|
1123
|
+
}
|
|
1124
|
+
if (source !== "@angular/core") return;
|
|
1125
|
+
for (const specifier of node.specifiers ?? []) {
|
|
1126
|
+
if (specifier.type === "ImportSpecifier" && getPropertyName(specifier.imported) === "inject") injectNames.add(specifier.local.name);
|
|
1127
|
+
if (specifier.type === "ImportNamespaceSpecifier") angularNamespaces.add(specifier.local.name);
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
CallExpression(node) {
|
|
1131
|
+
const options = context.options[0] ?? {};
|
|
1132
|
+
const allowedFunctionNames = new Set(options.allowedFunctionNames ?? DEFAULT_ALLOWED_FUNCTION_NAMES);
|
|
1133
|
+
const checkUnimportedInject = options.checkUnimportedInject ?? false;
|
|
1134
|
+
const injectFunctionPrefixes = options.injectFunctionPrefixes ?? DEFAULT_INJECT_FUNCTION_PREFIXES;
|
|
1135
|
+
const injectFunctionSuffixes = options.injectFunctionSuffixes ?? DEFAULT_INJECT_FUNCTION_SUFFIXES;
|
|
1136
|
+
const inAllowedContext = isAllowedInjectionContext(context, node, allowedFunctionNames, injectFunctionPrefixes, injectFunctionSuffixes);
|
|
1137
|
+
if (isInjectLikeHelperCall(node, injectNames, injectFunctionPrefixes, injectFunctionSuffixes, runsInInjectionContextFunctionNames) && !inAllowedContext) {
|
|
1138
|
+
context.report({
|
|
1139
|
+
node: node.callee,
|
|
1140
|
+
messageId: "disallowedInject"
|
|
1141
|
+
});
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (!isAngularInjectCall(node, injectNames, angularNamespaces, checkUnimportedInject)) return;
|
|
1145
|
+
if (inAllowedContext) return;
|
|
1146
|
+
context.report({
|
|
1147
|
+
node: node.callee,
|
|
1148
|
+
messageId: "disallowedInject"
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
})
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
//#endregion
|
|
1157
|
+
export { plugin as default };
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@benjavicente/lint-angular",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Oxlint/ESLint-compatible rules for Angular.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"angular",
|
|
7
|
+
"eslint",
|
|
8
|
+
"oxlint"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.mjs",
|
|
17
|
+
"types": "dist/index.d.mts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.mts",
|
|
21
|
+
"import": "./dist/index.mjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "vp pack",
|
|
29
|
+
"fmt": "vp fmt src",
|
|
30
|
+
"lint": "vp lint src",
|
|
31
|
+
"test": "vp test run"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@oxlint/plugins": "^1.61.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/estree": "^1.0.8",
|
|
38
|
+
"oxlint-vitest-rule-tester": "workspace:*",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"vite-plus": "catalog:",
|
|
41
|
+
"vitest": "catalog:"
|
|
42
|
+
}
|
|
43
|
+
}
|