@dsivd/prestations-ng 19.0.7 → 19.0.8
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 +27 -1
- package/ESLINT_PLUGIN.md +131 -17
- package/INTRODUCTION_ANGULAR_SIGNALS.md +503 -0
- package/dsivd-prestations-ng-19.0.8.tgz +0 -0
- package/eslint/component-path.mjs +15 -0
- package/eslint/rules/no-direct-signal-mutation.mjs +370 -142
- package/eslint/rules/no-uninvoked-signal-in-template.mjs +1 -12
- package/eslint/signal-kinds.mjs +57 -0
- package/eslint/signal-names.mjs +43 -49
- package/fesm2022/dsivd-prestations-ng.mjs +487 -487
- package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
- package/package.json +1 -1
- package/src/eslint/component-path.mjs +15 -0
- package/src/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +365 -128
- package/src/eslint/rules/no-direct-signal-mutation.mjs +370 -142
- package/src/eslint/rules/no-uninvoked-signal-in-template.mjs +1 -12
- package/src/eslint/signal-kinds.mjs +57 -0
- package/src/eslint/signal-names.mjs +43 -49
- package/types/dsivd-prestations-ng.d.ts +1 -2
- package/dsivd-prestations-ng-19.0.7.tgz +0 -0
|
@@ -1,14 +1,52 @@
|
|
|
1
|
+
import { resolveComponentPath } from "../component-path.mjs";
|
|
2
|
+
import {
|
|
3
|
+
AMBIGUOUS,
|
|
4
|
+
COMPUTED,
|
|
5
|
+
factoryKind,
|
|
6
|
+
FROM_OBSERVABLE,
|
|
7
|
+
INPUT,
|
|
8
|
+
VIEW_QUERY,
|
|
9
|
+
WRITABLE,
|
|
10
|
+
} from "../signal-kinds.mjs";
|
|
11
|
+
import { collectSignalKinds } from "../signal-names.mjs";
|
|
1
12
|
import { handlerExpressionOf, isComponentReceiver } from "../template-ast.mjs";
|
|
2
13
|
|
|
3
14
|
/**
|
|
4
|
-
* ESLint rule to detect
|
|
5
|
-
* Warns against patterns like: model().property = value
|
|
15
|
+
* ESLint rule to detect mutations of the value held by a signal.
|
|
16
|
+
* Warns against patterns like: model().property = value, model().items.splice(i, 1)
|
|
6
17
|
* Suggests using .update() or .set() instead.
|
|
7
18
|
*
|
|
8
19
|
* Applies to TypeScript files and to Angular templates, where the mutation is also
|
|
9
20
|
* hidden in every two-way binding on a call result: [(ngModel)]="model().property".
|
|
21
|
+
*
|
|
22
|
+
* No signal's value is to be mutated, whatever its kind — a mutation notifies none of its
|
|
23
|
+
* dependents. A view query is the single exception: it hands out a DOM element or a
|
|
24
|
+
* component instance, and mutating that is the reason for asking for it.
|
|
25
|
+
*
|
|
26
|
+
* Only the signals the class really declares are inspected, resolved through the whole
|
|
27
|
+
* inheritance chain, so a plain method (`this.getConfig().items.push(x)`) is left alone.
|
|
10
28
|
*/
|
|
11
29
|
|
|
30
|
+
// Kinds a mutation is not reported on: a view query hands out a DOM element or a component
|
|
31
|
+
// instance, and an ambiguous declaration may well be one.
|
|
32
|
+
const TOLERATED_KINDS = new Set([VIEW_QUERY, AMBIGUOUS]);
|
|
33
|
+
|
|
34
|
+
// How the message names each kind that has no `.set()` to offer, with the fix that applies.
|
|
35
|
+
const READ_ONLY_ADVICE = {
|
|
36
|
+
[COMPUTED]: [
|
|
37
|
+
"a computed()",
|
|
38
|
+
"It is rebuilt from its sources, so the change is dropped on the next recomputation — mutate the signal it is computed from",
|
|
39
|
+
],
|
|
40
|
+
[INPUT]: [
|
|
41
|
+
"an input()",
|
|
42
|
+
"The value belongs to the parent component — ask it for the change through an output()",
|
|
43
|
+
],
|
|
44
|
+
[FROM_OBSERVABLE]: [
|
|
45
|
+
"a toSignal()",
|
|
46
|
+
"The value comes from the observable — change it upstream",
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
12
50
|
// Assignment operators accepted by the Angular expression parser.
|
|
13
51
|
const TEMPLATE_ASSIGNMENTS = new Set([
|
|
14
52
|
"=",
|
|
@@ -23,6 +61,44 @@ const TEMPLATE_ASSIGNMENTS = new Set([
|
|
|
23
61
|
"??=",
|
|
24
62
|
]);
|
|
25
63
|
|
|
64
|
+
// Methods that mutate in place: called on a signal's value, they change it without
|
|
65
|
+
// notifying the signal, exactly like an assignment. Their non-mutating counterparts
|
|
66
|
+
// (`toSorted`, `filter`, `concat`…) return a new value and are none of the rule's
|
|
67
|
+
// business.
|
|
68
|
+
const MUTATING_METHODS = new Set([
|
|
69
|
+
// Array
|
|
70
|
+
"push",
|
|
71
|
+
"pop",
|
|
72
|
+
"shift",
|
|
73
|
+
"unshift",
|
|
74
|
+
"splice",
|
|
75
|
+
"sort",
|
|
76
|
+
"reverse",
|
|
77
|
+
"fill",
|
|
78
|
+
"copyWithin",
|
|
79
|
+
// Map and Set
|
|
80
|
+
"set",
|
|
81
|
+
"add",
|
|
82
|
+
"delete",
|
|
83
|
+
"clear",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Iteration methods handing their callback an element of the receiver: mutating that
|
|
87
|
+
// element mutates the array the signal holds. `reduce` needs no exclusion — its first
|
|
88
|
+
// parameter is the accumulator, and only the first parameter is followed.
|
|
89
|
+
const ITERATION_METHODS = new Set([
|
|
90
|
+
"forEach",
|
|
91
|
+
"map",
|
|
92
|
+
"filter",
|
|
93
|
+
"find",
|
|
94
|
+
"findLast",
|
|
95
|
+
"findIndex",
|
|
96
|
+
"findLastIndex",
|
|
97
|
+
"some",
|
|
98
|
+
"every",
|
|
99
|
+
"flatMap",
|
|
100
|
+
]);
|
|
101
|
+
|
|
26
102
|
const TEMPLATE_MEMBER_READS = new Set([
|
|
27
103
|
"PropertyRead",
|
|
28
104
|
"SafePropertyRead",
|
|
@@ -30,13 +106,6 @@ const TEMPLATE_MEMBER_READS = new Set([
|
|
|
30
106
|
"SafeKeyedRead",
|
|
31
107
|
]);
|
|
32
108
|
|
|
33
|
-
const VIEW_QUERY_FACTORIES = new Set([
|
|
34
|
-
"viewChild",
|
|
35
|
-
"viewChildren",
|
|
36
|
-
"contentChild",
|
|
37
|
-
"contentChildren",
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
109
|
// ── TypeScript ───────────────────────────────────────────────────────────────
|
|
41
110
|
|
|
42
111
|
function getCalledName(callee) {
|
|
@@ -46,175 +115,249 @@ function getCalledName(callee) {
|
|
|
46
115
|
if (callee.type === "Identifier") {
|
|
47
116
|
return callee.name;
|
|
48
117
|
}
|
|
118
|
+
if (callee.type !== "MemberExpression") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (!callee.computed && callee.property.type === "Identifier") {
|
|
122
|
+
return callee.property.name;
|
|
123
|
+
}
|
|
124
|
+
// `users["push"](x)` is the same call as `users.push(x)`, written differently.
|
|
49
125
|
if (
|
|
50
|
-
callee.
|
|
51
|
-
|
|
52
|
-
callee.property.
|
|
126
|
+
callee.computed &&
|
|
127
|
+
callee.property.type === "Literal" &&
|
|
128
|
+
typeof callee.property.value === "string"
|
|
53
129
|
) {
|
|
54
|
-
return callee.property.
|
|
130
|
+
return callee.property.value;
|
|
55
131
|
}
|
|
56
132
|
return null;
|
|
57
133
|
}
|
|
58
134
|
|
|
135
|
+
function isObjectAssign(callee) {
|
|
136
|
+
return (
|
|
137
|
+
getCalledName(callee) === "assign" &&
|
|
138
|
+
callee.object?.type === "Identifier" &&
|
|
139
|
+
callee.object.name === "Object"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
59
143
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* -
|
|
63
|
-
*
|
|
64
|
-
*
|
|
144
|
+
* What a member chain is rooted at, the property and index reads stripped away:
|
|
145
|
+
* - `model().deep.property` => the `model()` call
|
|
146
|
+
* - `users[0].name` => the `users` name
|
|
147
|
+
*
|
|
148
|
+
* Only reads are walked through. A chain rooted at anything else — `items().filter(f)`,
|
|
149
|
+
* `[...items()]` — designates a value of its own, and mutating that value is not mutating
|
|
150
|
+
* the signal.
|
|
65
151
|
*/
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (node.type === "MemberExpression") {
|
|
71
|
-
return getFirstCallExpression(node.object);
|
|
152
|
+
function baseOf(node) {
|
|
153
|
+
let current = node;
|
|
154
|
+
while (current?.type === "MemberExpression") {
|
|
155
|
+
current = current.object;
|
|
72
156
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
157
|
+
return current;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findVariable(scope, name) {
|
|
161
|
+
for (let current = scope; current; current = current.upper) {
|
|
162
|
+
const found = current.variables.find((variable) => variable.name === name);
|
|
163
|
+
if (found) {
|
|
164
|
+
return found;
|
|
77
165
|
}
|
|
78
|
-
return node;
|
|
79
166
|
}
|
|
80
167
|
return null;
|
|
81
168
|
}
|
|
82
169
|
|
|
83
|
-
|
|
170
|
+
// Kind of signal a factory call produces, `undefined` for anything that is not one.
|
|
171
|
+
function signalFactoryKind(node) {
|
|
84
172
|
if (node?.type !== "CallExpression") {
|
|
85
|
-
return
|
|
173
|
+
return undefined;
|
|
86
174
|
}
|
|
87
|
-
// `viewChild.required(…)`
|
|
175
|
+
// `input.required(…)` and `viewChild.required(…)` unwrap to their factory.
|
|
88
176
|
let callee = node.callee;
|
|
89
177
|
while (callee?.type === "MemberExpression") {
|
|
90
178
|
callee = callee.object;
|
|
91
179
|
}
|
|
92
|
-
return callee?.type === "Identifier"
|
|
180
|
+
return callee?.type === "Identifier" ? factoryKind(callee.name) : undefined;
|
|
93
181
|
}
|
|
94
182
|
|
|
95
|
-
function
|
|
96
|
-
if (
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (visited.has(node)) {
|
|
100
|
-
return;
|
|
183
|
+
function signalRootCallKind(node, scopeContext) {
|
|
184
|
+
if (node.arguments.length !== 0) {
|
|
185
|
+
return undefined;
|
|
101
186
|
}
|
|
102
|
-
visited.add(node);
|
|
103
|
-
|
|
104
|
-
visitor(node);
|
|
105
187
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
walkAst(child, visitor, visited);
|
|
118
|
-
}
|
|
188
|
+
// model().prop = ... (a signal held by a variable of this file, resolved by scope)
|
|
189
|
+
if (node.callee.type === "Identifier") {
|
|
190
|
+
const variable = findVariable(
|
|
191
|
+
scopeContext.sourceCode.getScope(node.callee),
|
|
192
|
+
node.callee.name,
|
|
193
|
+
);
|
|
194
|
+
const definition =
|
|
195
|
+
variable?.defs.length === 1 ? variable.defs[0] : undefined;
|
|
196
|
+
return definition?.type === "Variable"
|
|
197
|
+
? signalFactoryKind(definition.node.init)
|
|
198
|
+
: undefined;
|
|
119
199
|
}
|
|
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
200
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
|
201
|
+
// this.model().prop = ... (a signal member of the class)
|
|
202
|
+
// component.model().prop = ... is not ours to judge: the receiver is foreign.
|
|
203
|
+
if (node.callee.type === "MemberExpression") {
|
|
204
|
+
if (node.callee.object?.type !== "ThisExpression") {
|
|
205
|
+
return undefined;
|
|
143
206
|
}
|
|
144
|
-
|
|
207
|
+
const name = getCalledName(node.callee);
|
|
208
|
+
return name ? scopeContext.memberKind(name) : undefined;
|
|
209
|
+
}
|
|
145
210
|
|
|
146
|
-
return
|
|
211
|
+
return undefined;
|
|
147
212
|
}
|
|
148
213
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Kind of signal whose value an expression evaluates to, `undefined` when it is not one.
|
|
216
|
+
* The expression may be the signal read itself (`this.model()`), a member of its value
|
|
217
|
+
* (`this.model().items`), or a name bound to either.
|
|
218
|
+
*/
|
|
219
|
+
function signalValueKind(node, scopeContext, seen = new Set()) {
|
|
220
|
+
const base = baseOf(node);
|
|
221
|
+
if (base?.type === "CallExpression") {
|
|
222
|
+
return signalRootCallKind(base, scopeContext);
|
|
223
|
+
}
|
|
224
|
+
return base?.type === "Identifier"
|
|
225
|
+
? boundNameSignalKind(base, scopeContext, seen)
|
|
226
|
+
: undefined;
|
|
152
227
|
}
|
|
153
228
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Kind of signal a name is bound to the value of. Two bindings are followed, both
|
|
231
|
+
* resolved by ESLint's scope analysis rather than guessed:
|
|
232
|
+
* - a variable written exactly once, `const users = this.model().users`;
|
|
233
|
+
* - the element parameter of an iteration callback,
|
|
234
|
+
* `this.model().users.forEach((user) => …)`.
|
|
235
|
+
*
|
|
236
|
+
* Anything crossing a function boundary — a value handed to a method, stored in a class
|
|
237
|
+
* field, or reassigned — is out of reach and left unreported.
|
|
238
|
+
*/
|
|
239
|
+
function boundNameSignalKind(identifier, scopeContext, seen) {
|
|
240
|
+
const variable = findVariable(
|
|
241
|
+
scopeContext.sourceCode.getScope(identifier),
|
|
242
|
+
identifier.name,
|
|
243
|
+
);
|
|
244
|
+
if (!variable || variable.defs.length !== 1 || seen.has(variable)) {
|
|
245
|
+
return undefined;
|
|
157
246
|
}
|
|
247
|
+
seen.add(variable);
|
|
248
|
+
const definition = variable.defs[0];
|
|
158
249
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
250
|
+
if (definition.type === "Variable") {
|
|
251
|
+
// A reassignment makes the binding unreliable: the name may hold something else by
|
|
252
|
+
// the time it is mutated.
|
|
253
|
+
const writes = variable.references.filter((reference) =>
|
|
254
|
+
reference.isWrite(),
|
|
255
|
+
);
|
|
256
|
+
return writes.length === 1
|
|
257
|
+
? signalValueKind(definition.node.init, scopeContext, seen)
|
|
258
|
+
: undefined;
|
|
162
259
|
}
|
|
163
260
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
261
|
+
if (definition.type === "Parameter") {
|
|
262
|
+
const callback = definition.node;
|
|
263
|
+
const call = callback.parent;
|
|
264
|
+
if (
|
|
265
|
+
callback.params[0] !== definition.name ||
|
|
266
|
+
call?.type !== "CallExpression" ||
|
|
267
|
+
call.arguments[0] !== callback ||
|
|
268
|
+
!ITERATION_METHODS.has(getCalledName(call.callee))
|
|
269
|
+
) {
|
|
270
|
+
return undefined;
|
|
169
271
|
}
|
|
170
|
-
return
|
|
272
|
+
return signalValueKind(call.callee.object, scopeContext, seen);
|
|
171
273
|
}
|
|
172
274
|
|
|
173
|
-
return
|
|
275
|
+
return undefined;
|
|
174
276
|
}
|
|
175
277
|
|
|
176
278
|
// ── Angular templates ────────────────────────────────────────────────────────
|
|
177
279
|
|
|
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
280
|
/**
|
|
193
|
-
* Template counterpart of `
|
|
281
|
+
* Template counterpart of `signalRootCallKind`. A template has no bare identifier:
|
|
194
282
|
* `model()` and `this.model()` both read the member directly on the component, so both
|
|
195
283
|
* collapse to `isComponentReceiver`. `map.get(key)` (arguments) and
|
|
196
|
-
* `component.inputElement()` (foreign receiver) are therefore
|
|
197
|
-
*
|
|
284
|
+
* `component.inputElement()` (foreign receiver) are therefore never signal reads, as in
|
|
285
|
+
* TypeScript.
|
|
198
286
|
*/
|
|
199
|
-
function
|
|
287
|
+
function signalRootTemplateCallKind(node, templateContext) {
|
|
200
288
|
if (node.args?.length !== 0) {
|
|
201
|
-
return
|
|
289
|
+
return undefined;
|
|
202
290
|
}
|
|
203
291
|
const callee = node.receiver;
|
|
204
292
|
if (!callee || !TEMPLATE_MEMBER_READS.has(callee.type)) {
|
|
205
|
-
return
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
return isComponentReceiver(callee.receiver)
|
|
296
|
+
? templateContext.memberKind(callee.name ?? "")
|
|
297
|
+
: undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Expression a template name is bound to, `undefined` when the name is a component
|
|
302
|
+
* member. Mutating through such a name mutates whatever the expression yields:
|
|
303
|
+
* `@for (user of model().users)` hands out the elements of the signal's array.
|
|
304
|
+
*
|
|
305
|
+
* The item of a `@for` is only bound inside its own block, which the ancestor walk
|
|
306
|
+
* enforces exactly; a `@let` is looked up by name, its declaration always preceding its
|
|
307
|
+
* uses.
|
|
308
|
+
*/
|
|
309
|
+
function boundTemplateExpression(read, templateContext) {
|
|
310
|
+
for (let current = read; current; current = current.parent) {
|
|
311
|
+
if (current.type === "ForLoopBlock" && current.item?.name === read.name) {
|
|
312
|
+
return current.expression?.ast ?? current.expression;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const declared = templateContext.letBindings.get(read.name);
|
|
316
|
+
return declared?.ast ?? declared;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Template counterpart of `signalValueKind`: the value a signal holds, a member of it, or
|
|
321
|
+
* a template name bound to either.
|
|
322
|
+
*/
|
|
323
|
+
function signalTemplateValueKind(node, templateContext, seen = new Set()) {
|
|
324
|
+
if (node?.type === "Call" || node?.type === "SafeCall") {
|
|
325
|
+
return signalRootTemplateCallKind(node, templateContext);
|
|
326
|
+
}
|
|
327
|
+
if (!TEMPLATE_MEMBER_READS.has(node?.type)) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Property and index reads are walked through; anything else roots a value of its own.
|
|
332
|
+
let base = node;
|
|
333
|
+
while (TEMPLATE_MEMBER_READS.has(base.receiver?.type)) {
|
|
334
|
+
base = base.receiver;
|
|
335
|
+
}
|
|
336
|
+
const receiver = base.receiver;
|
|
337
|
+
|
|
338
|
+
if (receiver?.type === "Call" || receiver?.type === "SafeCall") {
|
|
339
|
+
return signalRootTemplateCallKind(receiver, templateContext);
|
|
340
|
+
}
|
|
341
|
+
if (!isComponentReceiver(receiver) || seen.has(base.name)) {
|
|
342
|
+
return undefined;
|
|
206
343
|
}
|
|
207
|
-
|
|
344
|
+
seen.add(base.name);
|
|
345
|
+
return signalTemplateValueKind(
|
|
346
|
+
boundTemplateExpression(base, templateContext),
|
|
347
|
+
templateContext,
|
|
348
|
+
seen,
|
|
349
|
+
);
|
|
208
350
|
}
|
|
209
351
|
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return false;
|
|
352
|
+
// Method a template call invokes, `undefined` when the name is not statically known.
|
|
353
|
+
function templateMethodName(receiver) {
|
|
354
|
+
if (receiver?.name) {
|
|
355
|
+
return receiver.name;
|
|
215
356
|
}
|
|
216
|
-
|
|
217
|
-
return
|
|
357
|
+
// `users['push'](x)` is the same call as `users.push(x)`, written differently.
|
|
358
|
+
return typeof receiver?.key?.value === "string"
|
|
359
|
+
? receiver.key.value
|
|
360
|
+
: undefined;
|
|
218
361
|
}
|
|
219
362
|
|
|
220
363
|
export default {
|
|
@@ -222,47 +365,131 @@ export default {
|
|
|
222
365
|
type: "problem",
|
|
223
366
|
docs: {
|
|
224
367
|
description:
|
|
225
|
-
"Warn on
|
|
368
|
+
"Warn on mutations of the value held by a signal, which notify none of its dependents",
|
|
226
369
|
category: "Best Practices",
|
|
227
370
|
recommended: true,
|
|
228
371
|
},
|
|
229
372
|
messages: {
|
|
230
373
|
directMutation:
|
|
231
|
-
"Direct mutation of
|
|
374
|
+
"Direct mutation of a signal's value detected. Use .update() or .set() instead of direct assignment.",
|
|
375
|
+
inPlaceMutation:
|
|
376
|
+
"In-place mutation of a signal's value detected. `{{method}}` changes the value without notifying the signal: use .update() with a new value instead.",
|
|
377
|
+
readOnlyMutation:
|
|
378
|
+
"Mutation of the value of {{kind}} detected, which notifies none of its dependents. {{advice}}.",
|
|
232
379
|
},
|
|
233
380
|
},
|
|
234
381
|
|
|
235
382
|
create(context) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
383
|
+
// The class holding the signals: the linted file itself, or the component a template
|
|
384
|
+
// belongs to.
|
|
385
|
+
const declaringPath =
|
|
386
|
+
resolveComponentPath(context.filename) ?? context.filename;
|
|
387
|
+
const memberKinds = collectSignalKinds(declaringPath);
|
|
239
388
|
|
|
240
|
-
|
|
241
|
-
|
|
389
|
+
// `memberKinds` is undefined when the declaring file could not be read: its signals
|
|
390
|
+
// are unknown, so fall back to treating every argument-less accessor as a writable
|
|
391
|
+
// one. A false positive costs a disable comment; a missed mutation is silent.
|
|
392
|
+
const memberKind = (name) =>
|
|
393
|
+
memberKinds ? memberKinds.get(name) : WRITABLE;
|
|
394
|
+
|
|
395
|
+
const reportMutation = (node, kind, method) => {
|
|
396
|
+
if (!kind || TOLERATED_KINDS.has(kind)) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (kind !== WRITABLE) {
|
|
400
|
+
const [name, advice] = READ_ONLY_ADVICE[kind];
|
|
401
|
+
context.report({
|
|
402
|
+
node,
|
|
403
|
+
messageId: "readOnlyMutation",
|
|
404
|
+
data: { kind: name, advice },
|
|
405
|
+
});
|
|
406
|
+
} else if (method) {
|
|
407
|
+
context.report({ node, messageId: "inPlaceMutation", data: { method } });
|
|
408
|
+
} else {
|
|
409
|
+
context.report({ node, messageId: "directMutation" });
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const scopeContext = { memberKind, sourceCode: context.sourceCode };
|
|
414
|
+
// `@let` bindings, always declared before they are used.
|
|
415
|
+
const templateContext = { memberKind, letBindings: new Map() };
|
|
416
|
+
|
|
417
|
+
const checkTemplateInPlaceCall = (node) => {
|
|
418
|
+
const method = templateMethodName(node.receiver);
|
|
419
|
+
if (!method || !MUTATING_METHODS.has(method)) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const kind = signalTemplateValueKind(
|
|
423
|
+
node.receiver.receiver,
|
|
424
|
+
templateContext,
|
|
425
|
+
);
|
|
426
|
+
reportMutation(node, kind, method);
|
|
427
|
+
};
|
|
242
428
|
|
|
243
429
|
return {
|
|
244
430
|
// ── TypeScript ───────────────────────────────────────────────────────────
|
|
245
431
|
AssignmentExpression(node) {
|
|
246
|
-
if (node.left.type
|
|
432
|
+
if (node.left.type === "MemberExpression") {
|
|
433
|
+
reportMutation(node, signalValueKind(node.left.object, scopeContext));
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
UnaryExpression(node) {
|
|
438
|
+
// `delete model().users[0]` drops an entry of the value, notifying nothing.
|
|
439
|
+
if (
|
|
440
|
+
node.operator === "delete" &&
|
|
441
|
+
node.argument.type === "MemberExpression"
|
|
442
|
+
) {
|
|
443
|
+
reportMutation(
|
|
444
|
+
node,
|
|
445
|
+
signalValueKind(node.argument.object, scopeContext),
|
|
446
|
+
"delete",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
CallExpression(node) {
|
|
452
|
+
if (node.callee.type !== "MemberExpression") {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// `Object.assign(target, …)` writes into its first argument.
|
|
457
|
+
if (isObjectAssign(node.callee)) {
|
|
458
|
+
reportMutation(
|
|
459
|
+
node,
|
|
460
|
+
signalValueKind(node.arguments[0], scopeContext),
|
|
461
|
+
"Object.assign",
|
|
462
|
+
);
|
|
247
463
|
return;
|
|
248
464
|
}
|
|
249
465
|
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
|
|
466
|
+
const method = getCalledName(node.callee);
|
|
467
|
+
if (method && MUTATING_METHODS.has(method)) {
|
|
468
|
+
reportMutation(
|
|
469
|
+
node,
|
|
470
|
+
signalValueKind(node.callee.object, scopeContext),
|
|
471
|
+
method,
|
|
472
|
+
);
|
|
253
473
|
}
|
|
254
474
|
},
|
|
255
475
|
|
|
256
476
|
// ── Angular templates ────────────────────────────────────────────────────
|
|
477
|
+
LetDeclaration(node) {
|
|
478
|
+
templateContext.letBindings.set(node.name, node.value);
|
|
479
|
+
},
|
|
480
|
+
|
|
257
481
|
Binary(node) {
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
482
|
+
if (TEMPLATE_ASSIGNMENTS.has(node.operation)) {
|
|
483
|
+
reportMutation(
|
|
484
|
+
node,
|
|
485
|
+
signalTemplateValueKind(node.left, templateContext),
|
|
486
|
+
);
|
|
263
487
|
}
|
|
264
488
|
},
|
|
265
489
|
|
|
490
|
+
Call: checkTemplateInPlaceCall,
|
|
491
|
+
SafeCall: checkTemplateInPlaceCall,
|
|
492
|
+
|
|
266
493
|
BoundEvent(node) {
|
|
267
494
|
// `[(x)]="model().property"` makes Angular assign into the value of the signal.
|
|
268
495
|
// Reporting on the BoundEvent handler reports the binding exactly once.
|
|
@@ -270,9 +497,10 @@ export default {
|
|
|
270
497
|
return;
|
|
271
498
|
}
|
|
272
499
|
const handler = handlerExpressionOf(node);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
500
|
+
reportMutation(
|
|
501
|
+
handler,
|
|
502
|
+
signalTemplateValueKind(handler, templateContext),
|
|
503
|
+
);
|
|
276
504
|
},
|
|
277
505
|
};
|
|
278
506
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveComponentPath } from "../component-path.mjs";
|
|
1
2
|
import { collectSignalNames } from "../signal-names.mjs";
|
|
2
3
|
import { handlerExpressionOf, isComponentReceiver } from "../template-ast.mjs";
|
|
3
4
|
|
|
@@ -30,18 +31,6 @@ const TEMPLATE_BUILTINS = [
|
|
|
30
31
|
"$implicit",
|
|
31
32
|
];
|
|
32
33
|
|
|
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
34
|
|
|
46
35
|
export default {
|
|
47
36
|
meta: {
|