@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.
@@ -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 direct mutations of signal/getter results.
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.type === "MemberExpression" &&
51
- !callee.computed &&
52
- callee.property.type === "Identifier"
126
+ callee.computed &&
127
+ callee.property.type === "Literal" &&
128
+ typeof callee.property.value === "string"
53
129
  ) {
54
- return callee.property.name;
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
- * 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()
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 getFirstCallExpression(node) {
67
- if (!node) {
68
- return null;
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
- if (node.type === "CallExpression") {
74
- if (node.callee.type === "MemberExpression") {
75
- const nestedCall = getFirstCallExpression(node.callee.object);
76
- return nestedCall || node;
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
- function isViewQueryFactoryCall(node) {
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 false;
173
+ return undefined;
86
174
  }
87
- // `viewChild.required(…)` unwraps to its factory.
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" && VIEW_QUERY_FACTORIES.has(callee.name);
180
+ return callee?.type === "Identifier" ? factoryKind(callee.name) : undefined;
93
181
  }
94
182
 
95
- function walkAst(node, visitor, visited = new WeakSet()) {
96
- if (!node || typeof node !== "object") {
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
- 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
- }
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
- 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);
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 names;
211
+ return undefined;
147
212
  }
148
213
 
149
- function isViewQueryAccessorCall(callee, viewQueryAccessorNames) {
150
- const calledName = getCalledName(callee);
151
- return calledName ? viewQueryAccessorNames.has(calledName) : false;
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
- function isSignalLikeRootCall(node, viewQueryAccessorNames) {
155
- if (node.arguments.length !== 0) {
156
- return false;
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
- // model().prop = ... (local signal-like accessor)
160
- if (node.callee.type === "Identifier") {
161
- return !viewQueryAccessorNames.has(node.callee.name);
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
- // 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;
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 !isViewQueryAccessorCall(node.callee, viewQueryAccessorNames);
272
+ return signalValueKind(call.callee.object, scopeContext, seen);
171
273
  }
172
274
 
173
- return false;
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 `isSignalLikeRootCall`. A template has no bare identifier:
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 not assumed to be signal
197
- * reads, as in TypeScript.
284
+ * `component.inputElement()` (foreign receiver) are therefore never signal reads, as in
285
+ * TypeScript.
198
286
  */
199
- function isSignalLikeRootTemplateCall(node) {
287
+ function signalRootTemplateCallKind(node, templateContext) {
200
288
  if (node.args?.length !== 0) {
201
- return false;
289
+ return undefined;
202
290
  }
203
291
  const callee = node.receiver;
204
292
  if (!callee || !TEMPLATE_MEMBER_READS.has(callee.type)) {
205
- return false;
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
- return isComponentReceiver(callee.receiver);
344
+ seen.add(base.name);
345
+ return signalTemplateValueKind(
346
+ boundTemplateExpression(base, templateContext),
347
+ templateContext,
348
+ seen,
349
+ );
208
350
  }
209
351
 
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;
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
- const rootCall = getFirstTemplateCall(node.receiver);
217
- return !!rootCall && isSignalLikeRootTemplateCall(rootCall);
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 direct property mutations of function call results (signals, getters, etc.)",
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 function call result detected. Use .update() or .set() instead of direct assignment.",
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
- const viewQueryAccessorNames = collectViewQueryAccessorNames(
237
- context.sourceCode.ast,
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
- const report = (node) =>
241
- context.report({ node, messageId: "directMutation" });
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 !== "MemberExpression") {
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 rootCall = getFirstCallExpression(node.left.object);
251
- if (rootCall && isSignalLikeRootCall(rootCall, viewQueryAccessorNames)) {
252
- report(node);
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
- TEMPLATE_ASSIGNMENTS.has(node.operation) &&
260
- isTemplateCallResultMember(node.left)
261
- ) {
262
- report(node);
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
- if (isTemplateCallResultMember(handler)) {
274
- report(handler);
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: {