@dsivd/prestations-ng 19.0.6-beta.2 → 19.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +24 -0
- package/ESLINT_PLUGIN.md +177 -0
- package/README.md +3 -0
- package/UPGRADING_V19.md +24 -6
- package/dsivd-prestations-ng-19.0.6.tgz +0 -0
- package/eslint/configs/template-base.mjs +10 -0
- package/eslint/configs/template-recommended.mjs +15 -0
- package/eslint/configs/ts-base.mjs +10 -0
- package/eslint/configs/ts-recommended.mjs +12 -0
- package/eslint/index.mjs +14 -5
- package/eslint/rules/index.mjs +7 -1
- package/eslint/rules/no-direct-signal-mutation.mjs +240 -136
- package/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
- package/eslint/signal-names.mjs +232 -0
- package/eslint/template-ast.mjs +26 -0
- package/fesm2022/dsivd-prestations-ng.mjs +7 -9
- package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
- package/package.json +1 -1
- package/src/eslint/configs/__tests__/configs.test.mjs +70 -0
- package/src/eslint/configs/template-base.mjs +10 -0
- package/src/eslint/configs/template-recommended.mjs +15 -0
- package/src/eslint/configs/ts-base.mjs +10 -0
- package/src/eslint/configs/ts-recommended.mjs +12 -0
- package/src/eslint/index.mjs +14 -5
- package/src/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +86 -4
- package/src/eslint/rules/__tests__/no-uninvoked-signal-in-template.test.mjs +291 -0
- package/src/eslint/rules/index.mjs +7 -1
- package/src/eslint/rules/no-direct-signal-mutation.mjs +240 -136
- package/src/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
- package/src/eslint/signal-names.mjs +232 -0
- package/src/eslint/template-ast.mjs +26 -0
- package/types/dsivd-prestations-ng.d.ts +0 -2
- package/dsivd-prestations-ng-19.0.6-beta.2.tgz +0 -0
- package/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +0 -98
|
@@ -1,8 +1,222 @@
|
|
|
1
|
+
import { handlerExpressionOf, isComponentReceiver } from "../template-ast.mjs";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* ESLint rule to detect direct mutations of signal/getter results.
|
|
3
5
|
* Warns against patterns like: model().property = value
|
|
4
6
|
* Suggests using .update() or .set() instead.
|
|
7
|
+
*
|
|
8
|
+
* Applies to TypeScript files and to Angular templates, where the mutation is also
|
|
9
|
+
* hidden in every two-way binding on a call result: [(ngModel)]="model().property".
|
|
5
10
|
*/
|
|
11
|
+
|
|
12
|
+
// Assignment operators accepted by the Angular expression parser.
|
|
13
|
+
const TEMPLATE_ASSIGNMENTS = new Set([
|
|
14
|
+
"=",
|
|
15
|
+
"+=",
|
|
16
|
+
"-=",
|
|
17
|
+
"*=",
|
|
18
|
+
"/=",
|
|
19
|
+
"%=",
|
|
20
|
+
"**=",
|
|
21
|
+
"&&=",
|
|
22
|
+
"||=",
|
|
23
|
+
"??=",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const TEMPLATE_MEMBER_READS = new Set([
|
|
27
|
+
"PropertyRead",
|
|
28
|
+
"SafePropertyRead",
|
|
29
|
+
"KeyedRead",
|
|
30
|
+
"SafeKeyedRead",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const VIEW_QUERY_FACTORIES = new Set([
|
|
34
|
+
"viewChild",
|
|
35
|
+
"viewChildren",
|
|
36
|
+
"contentChild",
|
|
37
|
+
"contentChildren",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// ── TypeScript ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function getCalledName(callee) {
|
|
43
|
+
if (!callee) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (callee.type === "Identifier") {
|
|
47
|
+
return callee.name;
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
callee.type === "MemberExpression" &&
|
|
51
|
+
!callee.computed &&
|
|
52
|
+
callee.property.type === "Identifier"
|
|
53
|
+
) {
|
|
54
|
+
return callee.property.name;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Finds the first/root function call used as the base of a mutation chain.
|
|
61
|
+
* Examples:
|
|
62
|
+
* - model().property = x => model()
|
|
63
|
+
* - this.model().deep.prop = x => this.model()
|
|
64
|
+
* - this.query().el().x = y => this.query()
|
|
65
|
+
*/
|
|
66
|
+
function getFirstCallExpression(node) {
|
|
67
|
+
if (!node) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (node.type === "MemberExpression") {
|
|
71
|
+
return getFirstCallExpression(node.object);
|
|
72
|
+
}
|
|
73
|
+
if (node.type === "CallExpression") {
|
|
74
|
+
if (node.callee.type === "MemberExpression") {
|
|
75
|
+
const nestedCall = getFirstCallExpression(node.callee.object);
|
|
76
|
+
return nestedCall || node;
|
|
77
|
+
}
|
|
78
|
+
return node;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isViewQueryFactoryCall(node) {
|
|
84
|
+
if (node?.type !== "CallExpression") {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// `viewChild.required(…)` unwraps to its factory.
|
|
88
|
+
let callee = node.callee;
|
|
89
|
+
while (callee?.type === "MemberExpression") {
|
|
90
|
+
callee = callee.object;
|
|
91
|
+
}
|
|
92
|
+
return callee?.type === "Identifier" && VIEW_QUERY_FACTORIES.has(callee.name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function walkAst(node, visitor, visited = new WeakSet()) {
|
|
96
|
+
if (!node || typeof node !== "object") {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (visited.has(node)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
visited.add(node);
|
|
103
|
+
|
|
104
|
+
visitor(node);
|
|
105
|
+
|
|
106
|
+
for (const key of Object.keys(node)) {
|
|
107
|
+
// ESLint AST nodes include cyclic parent references.
|
|
108
|
+
if (key === "parent") {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const child = node[key];
|
|
112
|
+
if (Array.isArray(child)) {
|
|
113
|
+
for (const element of child) {
|
|
114
|
+
walkAst(element, visitor, visited);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
walkAst(child, visitor, visited);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A view query signal is read-only: `.set()` does not exist on it, so a mutation
|
|
123
|
+
// reached through one must not be reported.
|
|
124
|
+
function collectViewQueryAccessorNames(ast) {
|
|
125
|
+
const names = new Set();
|
|
126
|
+
|
|
127
|
+
walkAst(ast, (node) => {
|
|
128
|
+
if (
|
|
129
|
+
node.type === "VariableDeclarator" &&
|
|
130
|
+
node.id?.type === "Identifier" &&
|
|
131
|
+
isViewQueryFactoryCall(node.init)
|
|
132
|
+
) {
|
|
133
|
+
names.add(node.id.name);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
(node.type === "PropertyDefinition" || node.type === "ClassProperty") &&
|
|
139
|
+
node.key?.type === "Identifier" &&
|
|
140
|
+
isViewQueryFactoryCall(node.value)
|
|
141
|
+
) {
|
|
142
|
+
names.add(node.key.name);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return names;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isViewQueryAccessorCall(callee, viewQueryAccessorNames) {
|
|
150
|
+
const calledName = getCalledName(callee);
|
|
151
|
+
return calledName ? viewQueryAccessorNames.has(calledName) : false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isSignalLikeRootCall(node, viewQueryAccessorNames) {
|
|
155
|
+
if (node.arguments.length !== 0) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// model().prop = ... (local signal-like accessor)
|
|
160
|
+
if (node.callee.type === "Identifier") {
|
|
161
|
+
return !viewQueryAccessorNames.has(node.callee.name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// this.model().prop = ... (component/service signal accessor)
|
|
165
|
+
// component.model().prop = ... should not be assumed to be a signal read.
|
|
166
|
+
if (node.callee.type === "MemberExpression") {
|
|
167
|
+
if (node.callee.object?.type !== "ThisExpression") {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return !isViewQueryAccessorCall(node.callee, viewQueryAccessorNames);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Angular templates ────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
// Template counterpart of `getFirstCallExpression`.
|
|
179
|
+
function getFirstTemplateCall(node) {
|
|
180
|
+
if (!node) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (TEMPLATE_MEMBER_READS.has(node.type)) {
|
|
184
|
+
return getFirstTemplateCall(node.receiver);
|
|
185
|
+
}
|
|
186
|
+
if (node.type === "Call" || node.type === "SafeCall") {
|
|
187
|
+
return getFirstTemplateCall(node.receiver) || node;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Template counterpart of `isSignalLikeRootCall`. A template has no bare identifier:
|
|
194
|
+
* `model()` and `this.model()` both read the member directly on the component, so both
|
|
195
|
+
* collapse to `isComponentReceiver`. `map.get(key)` (arguments) and
|
|
196
|
+
* `component.inputElement()` (foreign receiver) are therefore not assumed to be signal
|
|
197
|
+
* reads, as in TypeScript.
|
|
198
|
+
*/
|
|
199
|
+
function isSignalLikeRootTemplateCall(node) {
|
|
200
|
+
if (node.args?.length !== 0) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const callee = node.receiver;
|
|
204
|
+
if (!callee || !TEMPLATE_MEMBER_READS.has(callee.type)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return isComponentReceiver(callee.receiver);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Template counterpart of the TypeScript checks: a member of a signal-like call
|
|
211
|
+
// result, such as `model().property` or `this.model().array[0]`.
|
|
212
|
+
function isTemplateCallResultMember(node) {
|
|
213
|
+
if (!node || !TEMPLATE_MEMBER_READS.has(node.type)) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const rootCall = getFirstTemplateCall(node.receiver);
|
|
217
|
+
return !!rootCall && isSignalLikeRootTemplateCall(rootCall);
|
|
218
|
+
}
|
|
219
|
+
|
|
6
220
|
export default {
|
|
7
221
|
meta: {
|
|
8
222
|
type: "problem",
|
|
@@ -19,157 +233,47 @@ export default {
|
|
|
19
233
|
},
|
|
20
234
|
|
|
21
235
|
create(context) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
]);
|
|
29
|
-
const viewQueryAccessorNames = collectViewQueryAccessorNames(sourceCode.ast);
|
|
236
|
+
const viewQueryAccessorNames = collectViewQueryAccessorNames(
|
|
237
|
+
context.sourceCode.ast,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const report = (node) =>
|
|
241
|
+
context.report({ node, messageId: "directMutation" });
|
|
30
242
|
|
|
31
243
|
return {
|
|
244
|
+
// ── TypeScript ───────────────────────────────────────────────────────────
|
|
32
245
|
AssignmentExpression(node) {
|
|
33
246
|
if (node.left.type !== "MemberExpression") {
|
|
34
247
|
return;
|
|
35
248
|
}
|
|
36
249
|
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
node,
|
|
41
|
-
messageId: "directMutation",
|
|
42
|
-
});
|
|
250
|
+
const rootCall = getFirstCallExpression(node.left.object);
|
|
251
|
+
if (rootCall && isSignalLikeRootCall(rootCall, viewQueryAccessorNames)) {
|
|
252
|
+
report(node);
|
|
43
253
|
}
|
|
44
254
|
},
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
function isSignalLikeRootCall(node) {
|
|
48
|
-
if (node.arguments.length !== 0) {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// model().prop = ... (local signal-like accessor)
|
|
53
|
-
if (node.callee.type === "Identifier") {
|
|
54
|
-
return !viewQueryAccessorNames.has(node.callee.name);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// this.model().prop = ... (component/service signal accessor)
|
|
58
|
-
// component.model().prop = ... should not be assumed to be a signal read.
|
|
59
|
-
if (node.callee.type === "MemberExpression") {
|
|
60
|
-
if (node.callee.object?.type !== "ThisExpression") {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
return !isViewQueryAccessorCall(node.callee);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function isViewQueryAccessorCall(callee) {
|
|
70
|
-
const calledName = getCalledName(callee);
|
|
71
|
-
return calledName ? viewQueryAccessorNames.has(calledName) : false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function getCalledName(callee) {
|
|
75
|
-
if (!callee) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
if (callee.type === "Identifier") {
|
|
79
|
-
return callee.name;
|
|
80
|
-
}
|
|
81
|
-
if (
|
|
82
|
-
callee.type === "MemberExpression" &&
|
|
83
|
-
!callee.computed &&
|
|
84
|
-
callee.property.type === "Identifier"
|
|
85
|
-
) {
|
|
86
|
-
return callee.property.name;
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Finds the first/root function call used as the base of a mutation chain.
|
|
93
|
-
* Examples:
|
|
94
|
-
* - model().property = x => model()
|
|
95
|
-
* - this.model().deep.prop = x => this.model()
|
|
96
|
-
* - this.query().el().x = y => this.query()
|
|
97
|
-
*/
|
|
98
|
-
function getFirstCallExpression(node) {
|
|
99
|
-
if (!node) {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
if (node.type === "MemberExpression") {
|
|
103
|
-
return getFirstCallExpression(node.object);
|
|
104
|
-
}
|
|
105
|
-
if (node.type === "CallExpression") {
|
|
106
|
-
if (node.callee.type === "MemberExpression") {
|
|
107
|
-
const nestedCall = getFirstCallExpression(node.callee.object);
|
|
108
|
-
return nestedCall || node;
|
|
109
|
-
}
|
|
110
|
-
return node;
|
|
111
|
-
}
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function collectViewQueryAccessorNames(ast) {
|
|
116
|
-
const names = new Set();
|
|
117
|
-
|
|
118
|
-
walkAst(ast, (node) => {
|
|
119
|
-
if (
|
|
120
|
-
node.type === "VariableDeclarator" &&
|
|
121
|
-
node.id?.type === "Identifier" &&
|
|
122
|
-
isViewQueryFactoryCall(node.init)
|
|
123
|
-
) {
|
|
124
|
-
names.add(node.id.name);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
255
|
|
|
256
|
+
// ── Angular templates ────────────────────────────────────────────────────
|
|
257
|
+
Binary(node) {
|
|
128
258
|
if (
|
|
129
|
-
(node.
|
|
130
|
-
node.
|
|
131
|
-
isViewQueryFactoryCall(node.value)
|
|
259
|
+
TEMPLATE_ASSIGNMENTS.has(node.operation) &&
|
|
260
|
+
isTemplateCallResultMember(node.left)
|
|
132
261
|
) {
|
|
133
|
-
|
|
262
|
+
report(node);
|
|
134
263
|
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return names;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isViewQueryFactoryCall(node) {
|
|
141
|
-
return (
|
|
142
|
-
node?.type === "CallExpression" &&
|
|
143
|
-
node.callee?.type === "Identifier" &&
|
|
144
|
-
viewQueryFactoryNames.has(node.callee.name)
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function walkAst(node, visitor, visited = new WeakSet()) {
|
|
149
|
-
if (!node || typeof node !== "object") {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
if (visited.has(node)) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
visited.add(node);
|
|
156
|
-
|
|
157
|
-
visitor(node);
|
|
264
|
+
},
|
|
158
265
|
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
266
|
+
BoundEvent(node) {
|
|
267
|
+
// `[(x)]="model().property"` makes Angular assign into the value of the signal.
|
|
268
|
+
// Reporting on the BoundEvent handler reports the binding exactly once.
|
|
269
|
+
if (!node.name?.endsWith("Change")) {
|
|
270
|
+
return;
|
|
163
271
|
}
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
walkAst(element, visitor, visited);
|
|
168
|
-
}
|
|
169
|
-
} else {
|
|
170
|
-
walkAst(child, visitor, visited);
|
|
272
|
+
const handler = handlerExpressionOf(node);
|
|
273
|
+
if (isTemplateCallResultMember(handler)) {
|
|
274
|
+
report(handler);
|
|
171
275
|
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
276
|
+
},
|
|
277
|
+
};
|
|
174
278
|
},
|
|
175
279
|
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { collectSignalNames } from "../signal-names.mjs";
|
|
2
|
+
import { handlerExpressionOf, isComponentReceiver } from "../template-ast.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ESLint rule to detect signals referenced without being called in a template.
|
|
6
|
+
* Warns against patterns like: {{ mySignal }}, !mySignal, mySignal + 'x'
|
|
7
|
+
* Suggests calling the signal instead: mySignal()
|
|
8
|
+
*
|
|
9
|
+
* The compiler only covers part of these cases (NG8109 / NG8117): it lets through
|
|
10
|
+
* the negation (`!mySignal`, always false) and the concatenation (`mySignal + 'x'`,
|
|
11
|
+
* which injects the source code of the function).
|
|
12
|
+
*
|
|
13
|
+
* Signal names are resolved from the template's twin `.ts` and from its inheritance
|
|
14
|
+
* chain, base classes of other packages included. Nothing has to be configured.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// `mySignal.set(…)` targets the signal itself, not its value: no parentheses needed.
|
|
18
|
+
const SIGNAL_MEMBERS = new Set(["set", "update", "asReadonly"]);
|
|
19
|
+
|
|
20
|
+
// Implicit template variables: never signals of the component.
|
|
21
|
+
const TEMPLATE_BUILTINS = [
|
|
22
|
+
"$event",
|
|
23
|
+
"$index",
|
|
24
|
+
"$count",
|
|
25
|
+
"$first",
|
|
26
|
+
"$last",
|
|
27
|
+
"$even",
|
|
28
|
+
"$odd",
|
|
29
|
+
"$any",
|
|
30
|
+
"$implicit",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// An inline template is a virtual block whose path is derived from the `.ts`, and whose
|
|
34
|
+
// name ends with `.html` too: `foo.component.ts/1_inline-template-….component.html`.
|
|
35
|
+
// It has to be matched BEFORE the external template, otherwise it resolves to garbage.
|
|
36
|
+
function resolveComponentPath(filename) {
|
|
37
|
+
const inlineMatch = filename.match(/^(.*\.ts)(?:[/\\]|$)/);
|
|
38
|
+
if (inlineMatch) {
|
|
39
|
+
return inlineMatch[1];
|
|
40
|
+
}
|
|
41
|
+
return filename.endsWith(".html")
|
|
42
|
+
? `${filename.slice(0, -".html".length)}.ts`
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: {
|
|
50
|
+
description:
|
|
51
|
+
"Warn on signals referenced without being called in an Angular template",
|
|
52
|
+
category: "Best Practices",
|
|
53
|
+
recommended: true,
|
|
54
|
+
},
|
|
55
|
+
fixable: "code",
|
|
56
|
+
schema: [],
|
|
57
|
+
messages: {
|
|
58
|
+
uninvoked:
|
|
59
|
+
"Signal '{{name}}' is referenced without being called. Use '{{name}}()' instead: a bare reference is the function itself, always truthy and serialized as source code.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
create(context) {
|
|
64
|
+
const componentPath = resolveComponentPath(context.filename);
|
|
65
|
+
if (!componentPath) {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const signalNames = collectSignalNames(componentPath);
|
|
70
|
+
if (signalNames.size === 0) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Names bound by the template (#ref, @for items, @if aliases, @let, let-x): they
|
|
75
|
+
// shadow the class members, so they are never reported.
|
|
76
|
+
const shadowed = new Set(TEMPLATE_BUILTINS);
|
|
77
|
+
// References that already carry parentheses (`x()`) or that address the signal
|
|
78
|
+
// itself (`x.set(…)`).
|
|
79
|
+
const exempt = new Set();
|
|
80
|
+
// Spans already handled, by position. Holds the written positions (lvalue): a
|
|
81
|
+
// writable signal is bound WITHOUT parentheses — `[(x)]="mySignal"` — Angular
|
|
82
|
+
// calling `.set()` itself. A two-way binding also duplicates its expression
|
|
83
|
+
// (BoundAttribute + BoundEvent), so the same set dedupes the reports.
|
|
84
|
+
const handledSpans = new Set();
|
|
85
|
+
const candidates = [];
|
|
86
|
+
|
|
87
|
+
const markHandled = (node) => {
|
|
88
|
+
const { start, end } = node?.sourceSpan ?? {};
|
|
89
|
+
if (typeof start === "number") {
|
|
90
|
+
handledSpans.add(`${start}:${end}`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const rememberDeclarations = (node) => {
|
|
95
|
+
for (const declaration of [
|
|
96
|
+
...(node.references ?? []),
|
|
97
|
+
...(node.variables ?? []),
|
|
98
|
+
]) {
|
|
99
|
+
shadowed.add(declaration.name);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
Call: (node) => exempt.add(node.receiver),
|
|
105
|
+
SafeCall: (node) => exempt.add(node.receiver),
|
|
106
|
+
Element: rememberDeclarations,
|
|
107
|
+
Template: rememberDeclarations,
|
|
108
|
+
Content: rememberDeclarations,
|
|
109
|
+
BoundEvent(node) {
|
|
110
|
+
// `[(x)]="expr"` generates a BoundEvent `xChange` whose handler IS the written
|
|
111
|
+
// target. Its span is the one of the twin BoundAttribute expression: marking
|
|
112
|
+
// the span neutralizes both copies at once.
|
|
113
|
+
if (node.name?.endsWith("Change")) {
|
|
114
|
+
markHandled(handlerExpressionOf(node));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
Binary(node) {
|
|
118
|
+
if (node.operation === "=") {
|
|
119
|
+
markHandled(node.left);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
LetDeclaration(node) {
|
|
123
|
+
shadowed.add(node.name);
|
|
124
|
+
},
|
|
125
|
+
IfBlockBranch(node) {
|
|
126
|
+
if (node.expressionAlias) {
|
|
127
|
+
shadowed.add(node.expressionAlias.name);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
ForLoopBlock(node) {
|
|
131
|
+
shadowed.add(node.item.name);
|
|
132
|
+
for (const contextVariable of node.contextVariables ?? []) {
|
|
133
|
+
shadowed.add(contextVariable.name);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
PropertyRead(node) {
|
|
137
|
+
if (SIGNAL_MEMBERS.has(node.name)) {
|
|
138
|
+
exempt.add(node.receiver);
|
|
139
|
+
}
|
|
140
|
+
if (isComponentReceiver(node.receiver) && signalNames.has(node.name)) {
|
|
141
|
+
candidates.push(node);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
"Program:exit"() {
|
|
145
|
+
for (const node of candidates) {
|
|
146
|
+
const { start, end } = node.sourceSpan;
|
|
147
|
+
const span = `${start}:${end}`;
|
|
148
|
+
if (
|
|
149
|
+
exempt.has(node) ||
|
|
150
|
+
shadowed.has(node.name) ||
|
|
151
|
+
handledSpans.has(span)
|
|
152
|
+
) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
handledSpans.add(span);
|
|
156
|
+
|
|
157
|
+
context.report({
|
|
158
|
+
messageId: "uninvoked",
|
|
159
|
+
data: { name: node.name },
|
|
160
|
+
loc: {
|
|
161
|
+
start: context.sourceCode.getLocFromIndex(start),
|
|
162
|
+
end: context.sourceCode.getLocFromIndex(end),
|
|
163
|
+
},
|
|
164
|
+
fix: (fixer) => fixer.insertTextAfterRange([start, end], "()"),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
};
|