@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/package.json CHANGED
@@ -40,7 +40,7 @@
40
40
  "ng-update": {
41
41
  "migrations": "./schematics/migration-collection.json"
42
42
  },
43
- "version": "19.0.7",
43
+ "version": "19.0.8",
44
44
  "module": "fesm2022/dsivd-prestations-ng.mjs",
45
45
  "typings": "types/dsivd-prestations-ng.d.ts",
46
46
  "exports": {
@@ -0,0 +1,15 @@
1
+ // Path of the component a linted template belongs to, `undefined` when the file is not a
2
+ // template.
3
+ //
4
+ // An inline template is a virtual block whose path is derived from the `.ts`, and whose
5
+ // name ends with `.html` too: `foo.component.ts/1_inline-template-….component.html`.
6
+ // It has to be matched BEFORE the external template, otherwise it resolves to garbage.
7
+ export function resolveComponentPath(filename) {
8
+ const inlineMatch = filename.match(/^(.*\.ts)(?:[/\\]|$)/);
9
+ if (inlineMatch) {
10
+ return inlineMatch[1];
11
+ }
12
+ return filename.endsWith(".html")
13
+ ? `${filename.slice(0, -".html".length)}.ts`
14
+ : undefined;
15
+ }
@@ -1,98 +1,257 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import * as templateParser from '@angular-eslint/template-parser';
2
- import { describe, it } from 'vitest';
3
5
  import { RuleTester } from 'eslint';
6
+ import { beforeAll, describe, it } from 'vitest';
4
7
  import rule from '../no-direct-signal-mutation.mjs';
5
8
 
6
9
  describe('no-direct-signal-mutation', () => {
7
- const ruleTester = new RuleTester({
8
- languageOptions: {
9
- ecmaVersion: 2020,
10
- sourceType: 'module',
11
- },
10
+ // The rule only reports the signals a class really declares, resolved through its
11
+ // inheritance chain, so the fixtures have to exist on disk. They are laid out like a
12
+ // real project — the base class package under `node_modules` — so that module
13
+ // resolution is exercised.
14
+ let componentPath;
15
+ let templatePath;
16
+ let inlineTemplatePath;
17
+ let unknownTemplatePath;
18
+
19
+ const write = (path, content) => {
20
+ mkdirSync(join(path, '..'), { recursive: true });
21
+ writeFileSync(path, content, 'utf8');
22
+ };
23
+
24
+ beforeAll(() => {
25
+ const root = mkdtempSync(join(tmpdir(), 'no-direct-signal-mutation-'));
26
+
27
+ // A flattened declaration bundle, as published by ng-packagr: several classes in a
28
+ // single file, signal types qualified by the namespace they were imported under.
29
+ write(
30
+ join(root, 'node_modules', '@fake', 'kit', 'package.json'),
31
+ JSON.stringify({ name: '@fake/kit', types: 'index.d.ts' }),
32
+ );
33
+ write(
34
+ join(root, 'node_modules', '@fake', 'kit', 'index.d.ts'),
35
+ `import * as _angular_core from '@angular/core';
36
+ declare class KitInputComponent {
37
+ readonly inherited: _angular_core.ModelSignal<{ users: string[] }>;
38
+ readonly inheritedLabel: _angular_core.InputSignal<{ users: string[] }>;
39
+ readonly inheritedId: _angular_core.Signal<{ users: string[] }>;
40
+ }
41
+ declare class KitFieldComponent extends KitInputComponent {}
42
+ export { KitFieldComponent, KitInputComponent };`,
43
+ );
44
+
45
+ componentPath = join(root, 'src', 'sample.component.ts');
46
+ write(
47
+ componentPath,
48
+ `import { Component, computed, contentChildren, input, model, signal, viewChild } from '@angular/core';
49
+ import { toSignal } from '@angular/core/rxjs-interop';
50
+ import { KitFieldComponent } from '@fake/kit';
51
+
52
+ @Component({ selector: 'app-sample', templateUrl: './sample.component.html' })
53
+ export class SampleComponent extends KitFieldComponent {
54
+ readonly partenaire = signal({ name: '', users: [] });
55
+ readonly items = signal<string[]>([]);
56
+ readonly byId = signal(new Map<string, string>());
57
+ readonly tags = signal(new Set<string>());
58
+ readonly comment = model<string>('');
59
+
60
+ readonly double = computed(() => ({ users: [] }));
61
+ readonly formArray = computed(() => new FormArray([]));
62
+ readonly title = input.required<{ users: string[] }>();
63
+ readonly rows = viewChild('rows');
64
+ readonly slots = contentChildren('slot');
65
+ readonly fromStream = toSignal(this.source$);
66
+
67
+ getConfig() { return { users: [] }; }
68
+ }`,
69
+ );
70
+
71
+ templatePath = join(root, 'src', 'sample.component.html');
72
+ // Virtual block name produced by `angular.processInlineTemplates`.
73
+ inlineTemplatePath = join(
74
+ root,
75
+ 'src',
76
+ 'sample.component.ts',
77
+ '1_inline-template-sample.component.ts-1.component.html',
78
+ );
79
+ unknownTemplatePath = join(root, 'src', 'orphan.component.html');
80
+ });
81
+
82
+ const tsRuleTester = new RuleTester({
83
+ // 2021 at least: the rule covers `??=`, and the Angular parser accepts it too.
84
+ languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
12
85
  });
13
86
 
87
+ const ts = (code) => ({ code, filename: componentPath });
88
+ const invalidTs = (code, messageId) => ({ ...ts(code), errors: [{ messageId }] });
89
+
14
90
  it('should validate signal mutation rule', () => {
15
- ruleTester.run('no-direct-signal-mutation', rule, {
91
+ tsRuleTester.run('no-direct-signal-mutation', rule, {
16
92
  valid: [
17
- // Using .update()
18
- 'model.update((current) => ({ ...current, property: value }))',
19
- 'this.model.update((current) => ({ ...current, property: value }))',
20
- 'getSignal().update((current) => ({ ...current, property: value }))',
21
-
22
- // Using .set()
23
- 'model.set({ property: value })',
24
- 'this.model.set({ property: value })',
25
- 'getSignal().set({ property: value })',
93
+ // Going through the signal API
94
+ ts('this.partenaire.update((current) => ({ ...current, name: value }))'),
95
+ ts('this.partenaire.set({ name: value })'),
96
+ ts('this.comment.set(value)'),
26
97
 
27
98
  // Regular property assignments
28
- 'this.property = value',
29
- 'obj.property = value',
30
- 'this.obj.property = value',
31
-
32
- // Reading from function calls (no assignment)
33
- 'model().property',
34
- 'this.model().property',
35
- 'getSignal().property',
36
-
37
- // Method calls on function results
38
- 'model().someMethod()',
39
- 'this.model().someMethod()',
40
- 'getSignal().doSomething()',
41
-
42
- // Assignments to non-function results
43
- 'let x = value',
44
- 'const obj = { prop: value }',
45
- 'array[0] = value',
46
- 'pendingFilesByFormKey.get(formKey).files = []',
47
- 'this.pendingFilesByFormKey.get(formKey).url = baseUrl',
48
- 'const autocompleteComponent = viewChild("auto"); autocompleteComponent().inputElement().nativeElement.hidden = true',
49
- 'component.inputElement().nativeElement.value = ""',
50
- // A view query signal is read-only: there is no `.set()`/`.update()` to suggest,
51
- // so `viewChild.required()` has to be excluded like `viewChild()`.
52
- 'const requiredChild = viewChild.required("req"); requiredChild().nativeElement.hidden = true',
53
- 'const requiredRows = contentChildren.required("row"); requiredRows()[0].hidden = true',
54
-
55
- // Method chaining without assignment
56
- 'model().update((v) => v).subscribe()',
99
+ ts('this.property = value'),
100
+ ts('obj.property = value'),
101
+ ts('this.obj.property = value'),
102
+
103
+ // Reading from a signal, without mutating it
104
+ ts('this.partenaire().name'),
105
+ ts('this.items().toSorted()'),
106
+ ts('this.items().filter((x) => x.active)'),
107
+ ts('this.items().concat(other)'),
108
+ ts('[...this.items()].push(item)'),
109
+
110
+ // A plain method is not a signal, whatever it returns. This is what the
111
+ // inheritance-aware resolution buys: no guessing from the call shape.
112
+ ts('this.getConfig().users.push(user)'),
113
+ ts('this.getConfig().name = value'),
114
+ ts('this.notDeclaredAnywhere().name = value'),
115
+
116
+ // A view query is the one signal whose value is there to be mutated: it hands out
117
+ // a DOM element or a component instance.
118
+ ts('this.rows().nativeElement.hidden = true'),
119
+ ts('this.rows().items.push(item)'),
120
+ ts('const query = viewChild("rows"); query().items.push(item)'),
121
+ ts('const query = viewChild.required("rows"); query().items.push(item)'),
122
+ ts('const query = contentChildren("row"); query()[0].items.push(item)'),
123
+
124
+ // A published declaration spells a computed, a toSignal and a view query
125
+ // alike, `Signal<T>`. It has to be tolerated: this library publishes 54 view
126
+ // queries, on base classes every consumer extends.
127
+ ts('this.inheritedId().users.push(user)'),
128
+ ts('this.inheritedId().name = value'),
129
+
130
+ // A call taking arguments, or reached through a foreign receiver, is not a
131
+ // signal read.
132
+ ts('pendingFilesByFormKey.get(formKey).files = []'),
133
+ ts('pendingFilesByFormKey.get(formKey).files.push(file)'),
134
+ ts('component.partenaire().name = value'),
135
+ ts('component.partenaire().users.push(user)'),
136
+
137
+ // A local variable that holds no signal
138
+ ts('const plain = { users: [] }; plain.users.push(user)'),
139
+ ts('const viewQuery = viewChild("rows"); viewQuery().items.push(item)'),
140
+
141
+ // A chain reaching the mutation through a call designates a value of its own:
142
+ // `filter` and `slice` return a new array, so sorting THAT is nobody's business.
143
+ ts('this.items().filter((item) => item.active).sort()'),
144
+ ts('this.items().slice().sort()'),
145
+ ts('this.items().map((item) => item).push(item)'),
146
+ ts('this.items().concat(other).reverse()'),
147
+ ts('this.items().filter((item) => item.active)[0].name = value'),
148
+
149
+ // A name bound to a copy, not to the value itself
150
+ ts('const copy = [...this.items()]; copy.push(item)'),
151
+ ts('const copy = this.getConfig().users; copy.push(item)'),
152
+ // Reassigned: the name may hold something else by the time it is mutated
153
+ ts('let users = this.partenaire().users; users = other; users.push(item)'),
154
+ // A self reference must not send the resolution in circles
155
+ ts('const users = users; users.push(item)'),
156
+
157
+ // Following the callback parameter must not report on its own: the spread builds
158
+ // a new object instead of writing into `item`. Compare with the invalid twin,
159
+ // `map((item) => { item.done = true })`.
160
+ ts('this.items().map((item) => ({ ...item, done: true }))'),
161
+ ts('this.items().map((item) => item.done)'),
162
+ ts('this.items().reduce((acc, item) => acc.concat(item), [])'),
163
+ // Iterating something that is not a signal
164
+ ts('this.getConfig().users.forEach((user) => { user.done = true; })'),
165
+ ts('other.users.forEach((user) => { user.done = true; })'),
166
+ // `reduce` hands the accumulator first: mutating it is not mutating the signal
167
+ ts('this.items().reduce((acc, item) => { acc.push(item); return acc; }, [])'),
168
+
169
+ // `delete` and `Object.assign` on something that is not a signal
170
+ ts('delete this.plainObject.property'),
171
+ ts('delete this.getConfig().users[0]'),
172
+ ts('Object.assign({}, this.partenaire())'),
173
+ ts('Object.assign(target, this.partenaire())'),
174
+
175
+ // A computed member whose method does not mutate
176
+ ts('this.items()["toSorted"]()'),
57
177
  ],
58
178
 
59
179
  invalid: [
60
- {
61
- code: "model().property = value",
62
- errors: [{ messageId: 'directMutation' }],
63
- },
64
- {
65
- code: "this.model().property = value",
66
- errors: [{ messageId: 'directMutation' }],
67
- },
68
- {
69
- code: "getSignal().property = value",
70
- errors: [{ messageId: 'directMutation' }],
71
- },
72
- {
73
- code: "model().nested.property = value",
74
- errors: [{ messageId: 'directMutation' }],
75
- },
76
- {
77
- code: "this.model().deeply.nested.property = value",
78
- errors: [{ messageId: 'directMutation' }],
79
- },
80
- {
81
- code: "model().property += value",
82
- errors: [{ messageId: 'directMutation' }],
83
- },
84
- {
85
- code: "model().property -= value",
86
- errors: [{ messageId: 'directMutation' }],
87
- },
88
- {
89
- code: "model().property *= value",
90
- errors: [{ messageId: 'directMutation' }],
91
- },
92
- {
93
- code: "model().array[0] = value",
94
- errors: [{ messageId: 'directMutation' }],
95
- },
180
+ invalidTs('this.partenaire().name = value', 'directMutation'),
181
+ invalidTs('this.partenaire().deeply.nested.name = value', 'directMutation'),
182
+ invalidTs('this.partenaire().users[0] = value', 'directMutation'),
183
+ invalidTs('this.partenaire().users.length = 0', 'directMutation'),
184
+ invalidTs('this.partenaire().name += value', 'directMutation'),
185
+ invalidTs('this.partenaire().name ??= value', 'directMutation'),
186
+
187
+ // An in-place mutator changes the value without notifying the signal, exactly
188
+ // like an assignment does.
189
+ invalidTs('this.partenaire().users.splice(index, 1)', 'inPlaceMutation'),
190
+ invalidTs('this.items().push(item)', 'inPlaceMutation'),
191
+ invalidTs('this.items().sort((a, b) => a - b)', 'inPlaceMutation'),
192
+ invalidTs('this.partenaire().deeply.nested.users.reverse()', 'inPlaceMutation'),
193
+ // Map and Set mutators: safe to report now that only real signals are inspected.
194
+ invalidTs('this.byId().set(key, value)', 'inPlaceMutation'),
195
+ invalidTs('this.byId().delete(key)', 'inPlaceMutation'),
196
+ invalidTs('this.tags().add(tag)', 'inPlaceMutation'),
197
+ invalidTs('this.tags().clear()', 'inPlaceMutation'),
198
+
199
+ // A writable signal inherited from a published declaration bundle
200
+ invalidTs('this.inherited().users.push(user)', 'inPlaceMutation'),
201
+ invalidTs('this.inherited().name = value', 'directMutation'),
202
+
203
+ // A signal held by a plain variable of the file
204
+ invalidTs('const local = signal({ users: [] }); local().name = value', 'directMutation'),
205
+ invalidTs('const local = signal({ users: [] }); local().users.push(u)', 'inPlaceMutation'),
206
+ invalidTs('const local = model({ users: [] }); local().users.splice(i, 1)', 'inPlaceMutation'),
207
+
208
+ // `delete` drops an entry of the value, notifying nothing
209
+ invalidTs('delete this.partenaire().users[0]', 'inPlaceMutation'),
210
+ invalidTs('delete this.partenaire().name', 'inPlaceMutation'),
211
+ // `Object.assign` writes into its first argument
212
+ invalidTs('Object.assign(this.partenaire(), { name: value })', 'inPlaceMutation'),
213
+ invalidTs('Object.assign(this.partenaire().users, other)', 'inPlaceMutation'),
214
+ // A computed member is the same call written differently
215
+ invalidTs('this.partenaire().users["push"](user)', 'inPlaceMutation'),
216
+ invalidTs('this.partenaire().users["splice"](index, 1)', 'inPlaceMutation'),
217
+
218
+ // A name bound once to the value, or to a part of it
219
+ invalidTs('const users = this.partenaire().users; users.push(user)', 'inPlaceMutation'),
220
+ invalidTs('const users = this.partenaire().users; users[0].name = value', 'directMutation'),
221
+ invalidTs('const users = this.partenaire().users; delete users[0]', 'inPlaceMutation'),
222
+ invalidTs('const value = this.partenaire(); value.name = other', 'directMutation'),
223
+ invalidTs('const deep = this.partenaire().a.b.c; deep.push(item)', 'inPlaceMutation'),
224
+
225
+ // The element parameter of an iteration callback is a part of the value
226
+ invalidTs('this.items().forEach((item) => { item.done = true; })', 'directMutation'),
227
+ invalidTs('this.items().map((item) => { item.done = true; })', 'directMutation'),
228
+ invalidTs('this.items().forEach(function (item) { item.done = true; })', 'directMutation'),
229
+ invalidTs('this.items().filter((item) => item.tags.push(tag))', 'inPlaceMutation'),
230
+ invalidTs('this.partenaire().users.forEach((u) => u.tags.splice(0, 1))', 'inPlaceMutation'),
231
+ invalidTs('this.items().map((item) => Object.assign(item, { done: true }))', 'inPlaceMutation'),
232
+ invalidTs('this.items().map((item) => delete item.done)', 'inPlaceMutation'),
233
+ // A mutation hidden inside an otherwise honest spread
234
+ invalidTs('this.items().map((item) => ({ ...item, tags: item.tags.push(tag) }))', 'inPlaceMutation'),
235
+
236
+ // No signal's value is to be mutated, whatever its kind. Those without a `.set()`
237
+ // share one message, which names the kind and the fix that applies.
238
+ invalidTs('this.double().users.push(user)', 'readOnlyMutation'),
239
+ invalidTs('this.double().name = value', 'readOnlyMutation'),
240
+ invalidTs('delete this.double().users[0]', 'readOnlyMutation'),
241
+ invalidTs('const users = this.double().users; users.push(user)', 'readOnlyMutation'),
242
+ invalidTs('this.double().users.forEach((u) => { u.done = true; })', 'readOnlyMutation'),
243
+ invalidTs('const derived = computed(() => ({ users: [] })); derived().users.push(u)', 'readOnlyMutation'),
244
+ // An input's value belongs to the parent: mutating it corrupts the parent's state
245
+ // and notifies nothing either.
246
+ invalidTs('this.title().users.push(user)', 'readOnlyMutation'),
247
+ invalidTs('this.title().name = value', 'readOnlyMutation'),
248
+ invalidTs('this.fromStream().users.push(user)', 'readOnlyMutation'),
249
+ // Inherited from a published declaration bundle: `InputSignal` names its factory,
250
+ // while a bare `Signal<T>` only says read-only.
251
+ invalidTs('this.inheritedLabel().users.push(user)', 'readOnlyMutation'),
252
+ // The accepted false positive: a computed may legitimately hand out a live
253
+ // mutable object, and no type information is available to tell.
254
+ invalidTs('this.formArray().push(control)', 'readOnlyMutation'),
96
255
  ],
97
256
  });
98
257
  });
@@ -101,78 +260,156 @@ describe('no-direct-signal-mutation', () => {
101
260
  languageOptions: { parser: templateParser },
102
261
  });
103
262
 
263
+ const template = (code) => ({ code, filename: templatePath });
264
+ const invalidTemplate = (code, messageId) => ({
265
+ ...template(code),
266
+ errors: [{ messageId }],
267
+ });
268
+
104
269
  it('should validate signal mutation rule in templates', () => {
105
270
  templateRuleTester.run('no-direct-signal-mutation', rule, {
106
271
  valid: [
107
272
  // Reads only
108
- '{{ model().property }}',
109
- '<div [value]="model().property"></div>',
110
- '<div [value]="model().deeply.nested.property"></div>',
111
- '<div [value]="model().array[0]"></div>',
273
+ template('{{ partenaire().name }}'),
274
+ template('<div [value]="partenaire().deeply.nested.name"></div>'),
275
+ template('<div [value]="partenaire().users[0]"></div>'),
276
+ template('<div [value]="items().filter((x) => x.active)"></div>'),
277
+ template('<button (click)="items().toSorted()"></button>'),
112
278
 
113
279
  // Going through the signal API
114
- '<button (click)="model.set({ property: value })"></button>',
115
- '<button (click)="model.update((v) => ({ ...v, property: value }))"></button>',
280
+ template('<button (click)="partenaire.set({ name: value })"></button>'),
281
+ template('<button (click)="comment.set(value)"></button>'),
282
+ template('<button (click)="items.update((v) => [...v, item])"></button>'),
283
+
284
+ // Assignments that do not target a signal
285
+ template('<button (click)="property = value"></button>'),
286
+ template('<button (click)="obj.property = value"></button>'),
287
+ template('<button (click)="getConfig().name = value"></button>'),
288
+ template('<button (click)="getConfig().users.push(user)"></button>'),
116
289
 
117
- // Assignments that do not target a call result
118
- '<button (click)="property = value"></button>',
119
- '<button (click)="obj.property = value"></button>',
120
- '<button (click)="this.obj.property = value"></button>',
290
+ // A view query reached from a template used to be reported with a `.update()`
291
+ // that does not exist on it.
292
+ template('<button (click)="rows().nativeElement.hidden = true"></button>'),
293
+ template('<button (click)="rows().items.push(item)"></button>'),
294
+ template('@for (slot of slots(); track slot) { <button (click)="slot.done = 1"></button> }'),
121
295
 
122
296
  // Two-way binding on the signal itself: Angular calls `.set()`
123
- '<input [(ngModel)]="model" />',
124
- '<app-child [(value)]="comment"></app-child>',
125
-
126
- // Same exclusions as the TypeScript side: a call taking arguments, or reached
127
- // through a foreign receiver, is not assumed to be a signal read.
128
- '<button (click)="pendingFilesByFormKey.get(formKey).files = []"></button>',
129
- '<button (click)="this.pendingFilesByFormKey.get(formKey).url = baseUrl"></button>',
130
- '<button (click)="component.inputElement().nativeElement.value = &quot;&quot;"></button>',
131
- '<button (click)="obj.getter().property = value"></button>',
297
+ template('<input [(ngModel)]="comment" />'),
298
+ template('<app-child [(value)]="comment"></app-child>'),
299
+
300
+ // A call taking arguments, or reached through a foreign receiver
301
+ template('<button (click)="pendingFilesByFormKey.get(formKey).files = []"></button>'),
302
+ template('<button (click)="pendingFilesByFormKey.get(formKey).files.push(f)"></button>'),
303
+ template('<button (click)="component.inputElement().nativeElement.value = 1"></button>'),
304
+ template('<button (click)="component.partenaire().users.push(user)"></button>'),
305
+
132
306
  // An output named `…Change` that carries no assignment
133
- '<app-child (valueChange)="model.set($event)"></app-child>',
307
+ template('<app-child (valueChange)="comment.set($event)"></app-child>'),
308
+
309
+ // A `@for` item is only a signal's value when the loop iterates a signal
310
+ template('@for (u of getConfig().users; track u) { <button (click)="u.done = 1"></button> }'),
311
+ template('@for (u of rows().items; track u) { <button (click)="u.done = 1"></button> }'),
312
+ template('@for (u of other.users; track u) { <button (click)="u.done = 1"></button> }'),
313
+ // Reading the item mutates nothing
314
+ template('@for (u of items(); track u) { {{ u.name }} }'),
315
+ template('@let copy = getConfig().users; <button (click)="copy.push(item)"></button>'),
316
+
317
+ // A computed member whose method does not mutate
318
+ template(`<button (click)="items()['toSorted']()"></button>`),
319
+ template('<button (click)="items().filter(active).sort()"></button>'),
320
+ template('<button (click)="items().slice().sort()"></button>'),
134
321
  ],
135
322
 
136
323
  invalid: [
324
+ invalidTemplate('<button (click)="partenaire().name = value"></button>', 'directMutation'),
325
+ invalidTemplate('<button (click)="this.partenaire().name = value"></button>', 'directMutation'),
326
+ invalidTemplate('<button (click)="partenaire().deeply.nested.name = value"></button>', 'directMutation'),
327
+ invalidTemplate('<button (click)="partenaire().users[0] = value"></button>', 'directMutation'),
328
+ // Every compound assignment accepted by the Angular parser
329
+ invalidTemplate('<button (click)="partenaire().name += value"></button>', 'directMutation'),
330
+ invalidTemplate('<button (click)="partenaire().name ??= value"></button>', 'directMutation'),
331
+
332
+ // The mutation hidden in a two-way binding, reported once and not twice
333
+ invalidTemplate('<input [(ngModel)]="partenaire().name" />', 'directMutation'),
334
+ invalidTemplate('<app-child [(value)]="partenaire().deeply.name"></app-child>', 'directMutation'),
335
+ // An explicit `…Change` handler carrying the assignment: the Binary reports it,
336
+ // the BoundEvent must not report it a second time.
337
+ invalidTemplate('<app-child (valueChange)="partenaire().name = $event"></app-child>', 'directMutation'),
338
+
339
+ // In-place mutators
340
+ invalidTemplate('<button (click)="partenaire().users.splice(index, 1)"></button>', 'inPlaceMutation'),
341
+ invalidTemplate('<button (click)="items().push(item)"></button>', 'inPlaceMutation'),
342
+ invalidTemplate('<button (click)="partenaire()?.users?.sort()"></button>', 'inPlaceMutation'),
343
+ invalidTemplate('<button (click)="byId().set(key, value)"></button>', 'inPlaceMutation'),
344
+ invalidTemplate('<button (click)="tags().add(tag)"></button>', 'inPlaceMutation'),
345
+ invalidTemplate('<button (click)="inherited().users.push(user)"></button>', 'inPlaceMutation'),
346
+ invalidTemplate(`<button (click)="items()['push'](item)"></button>`, 'inPlaceMutation'),
347
+
348
+ // Mutating a `@for` item mutates the array the signal holds. The item is bound
349
+ // only inside its own block, which the ancestor walk enforces.
350
+ invalidTemplate('@for (u of items(); track u) { <button (click)="u.done = 1"></button> }', 'directMutation'),
351
+ invalidTemplate('@for (u of partenaire().users; track u) { <button (click)="u.tags.push(t)"></button> }', 'inPlaceMutation'),
352
+ invalidTemplate('@for (a of items(); track a) { @for (b of a.items; track b) { <button (click)="b.done = 1"></button> } }', 'directMutation'),
353
+
354
+ // Same through a `@let`
355
+ invalidTemplate('@let users = partenaire().users; <button (click)="users.push(user)"></button>', 'inPlaceMutation'),
356
+ invalidTemplate('@let value = partenaire(); <button (click)="value.name = other"></button>', 'directMutation'),
357
+
358
+ // Every other kind of signal, each with the advice that applies to it
359
+ invalidTemplate('<button (click)="double().users.push(user)"></button>', 'readOnlyMutation'),
360
+ invalidTemplate('<button (click)="formArray().push(control)"></button>', 'readOnlyMutation'),
361
+ invalidTemplate('<button (click)="title().users.push(user)"></button>', 'readOnlyMutation'),
362
+ invalidTemplate('<button (click)="fromStream().users.push(user)"></button>', 'readOnlyMutation'),
363
+ invalidTemplate('@for (u of double().users; track u) { <button (click)="u.done = 1"></button> }', 'readOnlyMutation'),
364
+ invalidTemplate('@for (u of title().users; track u) { <button (click)="u.done = 1"></button> }', 'readOnlyMutation'),
365
+ ],
366
+ });
367
+ });
368
+
369
+ it('should cover inline templates', () => {
370
+ templateRuleTester.run('no-direct-signal-mutation', rule, {
371
+ valid: [
372
+ { code: '{{ partenaire().name }}', filename: inlineTemplatePath },
137
373
  {
138
- code: '<button (click)="model().property = value"></button>',
139
- errors: [{ messageId: 'directMutation' }],
140
- },
141
- {
142
- code: '<button (click)="this.model().property = value"></button>',
143
- errors: [{ messageId: 'directMutation' }],
144
- },
145
- {
146
- code: '<button (click)="model().deeply.nested.property = value"></button>',
147
- errors: [{ messageId: 'directMutation' }],
148
- },
149
- {
150
- code: '<button (click)="model().array[0] = value"></button>',
151
- errors: [{ messageId: 'directMutation' }],
374
+ code: '<button (click)="getConfig().users.push(user)"></button>',
375
+ filename: inlineTemplatePath,
152
376
  },
153
- // Every compound assignment accepted by the Angular parser
377
+ ],
378
+ invalid: [
154
379
  {
155
- code: '<button (click)="model().property += value"></button>',
156
- errors: [{ messageId: 'directMutation' }],
380
+ code: '<button (click)="partenaire().users.splice(index, 1)"></button>',
381
+ filename: inlineTemplatePath,
382
+ errors: [{ messageId: 'inPlaceMutation' }],
157
383
  },
384
+ ],
385
+ });
386
+ });
387
+
388
+ // Without the declaring class, the signals are unknown. Reporting on the call shape
389
+ // alone can be wrong, but a false positive costs a disable comment while a missed
390
+ // mutation is silent.
391
+ it('should fall back to the call shape when the component cannot be read', () => {
392
+ templateRuleTester.run('no-direct-signal-mutation', rule, {
393
+ valid: [
158
394
  {
159
- code: '<button (click)="model().property ??= value"></button>',
160
- errors: [{ messageId: 'directMutation' }],
395
+ code: '<button (click)="component.whatever().name = value"></button>',
396
+ filename: unknownTemplatePath,
161
397
  },
162
- // The mutation hidden in a two-way binding, reported once and not twice
163
398
  {
164
- code: '<input [(ngModel)]="model().comment" />',
165
- errors: [{ messageId: 'directMutation' }],
399
+ code: '<button (click)="map.get(key).name = value"></button>',
400
+ filename: unknownTemplatePath,
166
401
  },
402
+ ],
403
+ invalid: [
167
404
  {
168
- code: '<app-child [(value)]="model().nested.property"></app-child>',
405
+ code: '<button (click)="whatever().name = value"></button>',
406
+ filename: unknownTemplatePath,
169
407
  errors: [{ messageId: 'directMutation' }],
170
408
  },
171
- // An explicit `…Change` handler carrying the assignment: the Binary reports it,
172
- // the BoundEvent must not report it a second time.
173
409
  {
174
- code: '<app-child (valueChange)="model().property = $event"></app-child>',
175
- errors: [{ messageId: 'directMutation' }],
410
+ code: '<button (click)="whatever().users.push(user)"></button>',
411
+ filename: unknownTemplatePath,
412
+ errors: [{ messageId: 'inPlaceMutation' }],
176
413
  },
177
414
  ],
178
415
  });