@dsivd/prestations-ng 19.0.6-beta.2 → 19.0.7

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +25 -0
  3. package/ESLINT_PLUGIN.md +198 -0
  4. package/README.md +3 -0
  5. package/UPGRADING_V19.md +38 -143
  6. package/dsivd-prestations-ng-19.0.7.tgz +0 -0
  7. package/eslint/configs/template-base.mjs +10 -0
  8. package/eslint/configs/template-recommended.mjs +24 -0
  9. package/eslint/configs/template-rules.mjs +17 -0
  10. package/eslint/configs/ts-base.mjs +10 -0
  11. package/eslint/configs/ts-recommended.mjs +142 -0
  12. package/eslint/configs/ts-rules.mjs +14 -0
  13. package/eslint/index.mjs +20 -5
  14. package/eslint/rules/index.mjs +7 -1
  15. package/eslint/rules/no-direct-signal-mutation.mjs +240 -136
  16. package/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
  17. package/eslint/signal-names.mjs +232 -0
  18. package/eslint/template-ast.mjs +26 -0
  19. package/fesm2022/dsivd-prestations-ng.mjs +7 -9
  20. package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
  21. package/package.json +1 -1
  22. package/src/eslint/configs/__tests__/configs.test.mjs +135 -0
  23. package/src/eslint/configs/template-base.mjs +10 -0
  24. package/src/eslint/configs/template-recommended.mjs +24 -0
  25. package/src/eslint/configs/template-rules.mjs +17 -0
  26. package/src/eslint/configs/ts-base.mjs +10 -0
  27. package/src/eslint/configs/ts-recommended.mjs +142 -0
  28. package/src/eslint/configs/ts-rules.mjs +14 -0
  29. package/src/eslint/index.mjs +20 -5
  30. package/src/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +86 -4
  31. package/src/eslint/rules/__tests__/no-uninvoked-signal-in-template.test.mjs +291 -0
  32. package/src/eslint/rules/index.mjs +7 -1
  33. package/src/eslint/rules/no-direct-signal-mutation.mjs +240 -136
  34. package/src/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
  35. package/src/eslint/signal-names.mjs +232 -0
  36. package/src/eslint/template-ast.mjs +26 -0
  37. package/types/dsivd-prestations-ng.d.ts +0 -2
  38. package/dsivd-prestations-ng-19.0.6-beta.2.tgz +0 -0
  39. package/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +0 -98
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.6-beta.2",
43
+ "version": "19.0.7",
44
44
  "module": "fesm2022/dsivd-prestations-ng.mjs",
45
45
  "typings": "types/dsivd-prestations-ng.d.ts",
46
46
  "exports": {
@@ -0,0 +1,135 @@
1
+ import { builtinRules } from 'eslint/use-at-your-own-risk';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import prestationsNg, { configs, plugin, rules } from '../../index.mjs';
5
+
6
+ const PLUGIN_KEY = '@dsivd/prestations-ng';
7
+
8
+ const configNames = Object.keys(configs);
9
+
10
+ // Configs that only register this library's rules, and the presets built on top of them.
11
+ const RULE_LAYERS = ['tsRules', 'templateRules'];
12
+ const PRESETS = [
13
+ ['tsRecommended', 'tsRules'],
14
+ ['templateRecommended', 'templateRules'],
15
+ ];
16
+
17
+ const isOff = (severity) =>
18
+ severity === 'off' ||
19
+ severity === 0 ||
20
+ (Array.isArray(severity) && (severity[0] === 'off' || severity[0] === 0));
21
+
22
+ // Rule ids a config turns on. Ids set to 'off' are excluded: ESLint accepts those even
23
+ // when the rule does not exist, which is precisely the escape hatch consumers rely on.
24
+ const enabledIdsOf = (config) =>
25
+ config.flatMap((entry) =>
26
+ Object.entries(entry.rules ?? {})
27
+ .filter(([, severity]) => !isOff(severity))
28
+ .map(([id]) => id),
29
+ );
30
+
31
+ const ownIdsOf = (config) =>
32
+ enabledIdsOf(config).filter((id) => id.startsWith(`${PLUGIN_KEY}/`));
33
+
34
+ const pluginsOf = (config) =>
35
+ Object.assign({}, ...config.map((entry) => entry.plugins ?? {}));
36
+
37
+ // An id is either `plugin-key/rule` (possibly nested, as in `@angular-eslint/template/x`)
38
+ // or a bare core rule.
39
+ const ruleExists = (id, plugins) => {
40
+ for (const [key, declared] of Object.entries(plugins)) {
41
+ if (id.startsWith(`${key}/`)) {
42
+ return Boolean(declared.rules?.[id.slice(key.length + 1)]);
43
+ }
44
+ }
45
+ return builtinRules.has(id);
46
+ };
47
+
48
+ describe('eslint configs', () => {
49
+ it('should expose a rule layer and a preset per lint target', () => {
50
+ expect(configNames).toEqual([
51
+ 'tsRecommended',
52
+ 'templateRecommended',
53
+ 'tsRules',
54
+ 'templateRules',
55
+ ]);
56
+ });
57
+
58
+ it.each(RULE_LAYERS)('%s should register the plugin first', (name) => {
59
+ const [base, ...rest] = configs[name];
60
+
61
+ expect(base.plugins).toEqual({ [PLUGIN_KEY]: plugin });
62
+ expect(base.rules).toBeUndefined();
63
+ expect(rest.every((entry) => entry.plugins === undefined)).toBe(true);
64
+ });
65
+
66
+ it.each(RULE_LAYERS)('%s should carry no third-party rule', (name) => {
67
+ expect(enabledIdsOf(configs[name])).toEqual(ownIdsOf(configs[name]));
68
+ });
69
+
70
+ it.each(configNames)('%s should name its own entries', (name) => {
71
+ const own = configs[name].filter((entry) =>
72
+ entry.name?.startsWith('prestations-ng/'),
73
+ );
74
+
75
+ expect(own.length).toBeGreaterThan(0);
76
+ });
77
+
78
+ // A config referencing a rule that does not exist breaks the lint of every consuming
79
+ // project at once, so it must never reach a release. `builtinRules` is an unstable
80
+ // ESLint export, but it is the only way to check core rule ids.
81
+ it.each(configNames)('%s should only enable existing rules', (name) => {
82
+ const plugins = pluginsOf(configs[name]);
83
+
84
+ for (const id of enabledIdsOf(configs[name])) {
85
+ expect(ruleExists(id, plugins), `unknown rule ${id}`).toBe(true);
86
+ }
87
+ });
88
+
89
+ // Two entries declaring the same plugin key from different copies is a hard ESLint
90
+ // failure, so the preset must register ours exactly once.
91
+ it.each(configNames)('%s should register the plugin once', (name) => {
92
+ const declarations = configs[name]
93
+ .map((entry) => entry.plugins?.[PLUGIN_KEY])
94
+ .filter(Boolean);
95
+
96
+ expect(declarations).toEqual([plugin]);
97
+ });
98
+
99
+ it.each(PRESETS)('%s should build on %s', (preset, layer) => {
100
+ expect(ownIdsOf(configs[preset])).toEqual(ownIdsOf(configs[layer]));
101
+ });
102
+
103
+ it('should enable every rule in at least one config', () => {
104
+ const enabled = new Set(configNames.flatMap((n) => ownIdsOf(configs[n])));
105
+
106
+ for (const ruleName of Object.keys(rules)) {
107
+ expect(enabled).toContain(`${PLUGIN_KEY}/${ruleName}`);
108
+ }
109
+ });
110
+
111
+ it.each(['tsRecommended', 'tsRules'])(
112
+ '%s should keep the template-only rule out',
113
+ (name) => {
114
+ expect(enabledIdsOf(configs[name])).not.toContain(
115
+ `${PLUGIN_KEY}/no-uninvoked-signal-in-template`,
116
+ );
117
+ },
118
+ );
119
+ });
120
+
121
+ describe('eslint plugin', () => {
122
+ it('should be named after the npm package', () => {
123
+ expect(plugin.meta.name).toBe(PLUGIN_KEY);
124
+ });
125
+
126
+ it('should mirror the named exports on the default export', () => {
127
+ expect(prestationsNg).toEqual({ configs, plugin, rules });
128
+ });
129
+
130
+ it.each(Object.entries(rules))('%s should be a usable rule', (_, rule) => {
131
+ expect(typeof rule.create).toBe('function');
132
+ expect(Object.keys(rule.meta.messages).length).toBeGreaterThan(0);
133
+ expect(rule.meta.type).toBe('problem');
134
+ });
135
+ });
@@ -0,0 +1,10 @@
1
+ // Registers the plugin without enabling any rule. Meant to be used through
2
+ // `templateRules` or `templateRecommended`, inside the `extends` of a block that
3
+ // already targets `**/*.html` and sets the Angular template parser.
4
+ const templateBase = (plugin) => ({
5
+ name: 'prestations-ng/template-base',
6
+ plugins: {
7
+ '@dsivd/prestations-ng': plugin,
8
+ },
9
+ });
10
+ export default templateBase;
@@ -0,0 +1,24 @@
1
+ import angular from 'angular-eslint';
2
+
3
+ import templateRules from './template-rules.mjs';
4
+
5
+ /**
6
+ * The house Angular template preset: the recommended and accessibility baselines, this
7
+ * library's own rules, and the conventions every prestations-ng project follows.
8
+ *
9
+ * Meant for the `extends` of a `**\/*.html` block. It brings its own parser and plugins,
10
+ * so do not spread `angular.configs.templateRecommended` alongside it.
11
+ */
12
+ const templateRecommended = (plugin) => [
13
+ ...angular.configs.templateRecommended,
14
+ ...angular.configs.templateAccessibility,
15
+ ...templateRules(plugin),
16
+ {
17
+ name: 'prestations-ng/template-recommended',
18
+ rules: {
19
+ '@angular-eslint/template/no-negated-async': 'off',
20
+ '@angular-eslint/template/button-has-type': 'error',
21
+ },
22
+ },
23
+ ];
24
+ export default templateRecommended;
@@ -0,0 +1,17 @@
1
+ import templateBase from './template-base.mjs';
2
+
3
+ // Only the rules written by this library. `templateRecommended` includes it, alongside
4
+ // the house style; use this layer directly to opt out of the latter.
5
+ const templateRules = (plugin) => [
6
+ templateBase(plugin),
7
+ {
8
+ name: 'prestations-ng/template-rules',
9
+ rules: {
10
+ // Also in `tsRules`: the rule carries both an ESTree and an Angular
11
+ // template visitor, and each fires on its own kind of file.
12
+ '@dsivd/prestations-ng/no-direct-signal-mutation': 'error',
13
+ '@dsivd/prestations-ng/no-uninvoked-signal-in-template': 'error',
14
+ },
15
+ },
16
+ ];
17
+ export default templateRules;
@@ -0,0 +1,10 @@
1
+ // Registers the plugin without enabling any rule. Meant to be used through
2
+ // `tsRules` or `tsRecommended`, inside the `extends` of a block that already
3
+ // targets `**/*.ts` and sets the TypeScript parser.
4
+ const tsBase = (plugin) => ({
5
+ name: 'prestations-ng/ts-base',
6
+ plugins: {
7
+ '@dsivd/prestations-ng': plugin,
8
+ },
9
+ });
10
+ export default tsBase;
@@ -0,0 +1,142 @@
1
+ import eslint from '@eslint/js';
2
+ import rxjs from '@smarttools/eslint-plugin-rxjs';
3
+ import angular from 'angular-eslint';
4
+ import importX from 'eslint-plugin-import-x';
5
+ import simpleImportSort from 'eslint-plugin-simple-import-sort';
6
+ import tseslint from 'typescript-eslint';
7
+
8
+ import tsRules from './ts-rules.mjs';
9
+
10
+ /**
11
+ * The house TypeScript preset: the shared baselines, this library's own rules, and the
12
+ * conventions every prestations-ng project follows.
13
+ *
14
+ * Meant for the `extends` of a `**\/*.ts` block. It brings its own parser and plugins,
15
+ * so do not spread `angular.configs.tsRecommended` alongside it — declaring the same
16
+ * plugin twice from two different copies makes ESLint fail outright.
17
+ */
18
+ const tsRecommended = (plugin) => [
19
+ eslint.configs.recommended,
20
+ ...tseslint.configs.recommended, // covers: no-explicit-any, ban-ts-comment,
21
+ // no-unused-expressions, no-var, no-unused-vars…
22
+ ...tseslint.configs.stylistic, // covers: array-type, consistent-type-assertions,
23
+ // dot-notation, no-inferrable-types (default options),
24
+ // no-empty-function (error), prefer-for-of,
25
+ // prefer-function-type…
26
+ ...angular.configs.tsRecommended, // covers: prefer-inject, no-empty-lifecycle-method,
27
+ // use-lifecycle-interface…
28
+ ...tsRules(plugin),
29
+ {
30
+ name: 'prestations-ng/ts-recommended',
31
+ plugins: {
32
+ rxjs,
33
+ 'simple-import-sort': simpleImportSort,
34
+ 'import-x': importX,
35
+ },
36
+ rules: {
37
+ // ── @angular-eslint ──────────────────────────────────────────────
38
+ '@angular-eslint/component-selector': [
39
+ 'error',
40
+ {
41
+ type: 'element',
42
+ prefix: 'app',
43
+ style: 'kebab-case',
44
+ },
45
+ ],
46
+ '@angular-eslint/directive-selector': [
47
+ 'error',
48
+ {
49
+ type: 'attribute',
50
+ prefix: 'app',
51
+ style: 'camelCase',
52
+ },
53
+ ],
54
+ '@angular-eslint/no-uncalled-signals': ['error'],
55
+ '@angular-eslint/prefer-signal-model': ['error'],
56
+ '@angular-eslint/prefer-signals': ['error'],
57
+
58
+ // ── @typescript-eslint ───────────────────────────────────────────
59
+ '@typescript-eslint/explicit-function-return-type': [
60
+ 'error',
61
+ {
62
+ allowExpressions: true,
63
+ allowTypedFunctionExpressions: true,
64
+ allowHigherOrderFunctions: true,
65
+ allowDirectConstAssertionInArrowFunctions: true,
66
+ },
67
+ ],
68
+ '@typescript-eslint/explicit-member-accessibility': [
69
+ 'error',
70
+ { accessibility: 'no-public' },
71
+ ],
72
+ '@typescript-eslint/member-ordering': 'error',
73
+ '@typescript-eslint/no-base-to-string': 'warn',
74
+ // stylistic enables no-inferrable-types with default options (ignoreParameters: false);
75
+ // override here to allow typed parameters explicitly
76
+ '@typescript-eslint/no-inferrable-types': [
77
+ 'error',
78
+ { ignoreParameters: true },
79
+ ],
80
+ // stylistic sets no-empty-function to error; downgrade to warn
81
+ '@typescript-eslint/no-empty-function': 'warn',
82
+ '@typescript-eslint/prefer-includes': 'warn',
83
+ '@typescript-eslint/return-await': ['error', 'never'],
84
+ '@typescript-eslint/typedef': ['error', { parameter: true }],
85
+ '@typescript-eslint/unified-signatures': 'error',
86
+ // off here — @typescript-eslint/no-shadow handles it correctly for TS
87
+ 'no-shadow': 'off',
88
+ '@typescript-eslint/no-shadow': ['error', { hoist: 'all' }],
89
+ '@typescript-eslint/no-unused-vars': [
90
+ 'error',
91
+ {
92
+ varsIgnorePattern: '^_',
93
+ argsIgnorePattern: '^_',
94
+ caughtErrorsIgnorePattern: '^_',
95
+ },
96
+ ],
97
+
98
+ // ── eslint core ──────────────────────────────────────────────────
99
+ 'arrow-body-style': 'error',
100
+ 'arrow-parens': ['error', 'as-needed'],
101
+ curly: 'error',
102
+ eqeqeq: ['error', 'always'],
103
+ 'guard-for-in': 'error',
104
+ 'no-bitwise': 'error',
105
+ 'no-caller': 'error',
106
+ 'no-console': ['error', { allow: ['log', 'warn', 'error'] }],
107
+ 'no-eval': 'error',
108
+ 'no-extra-boolean-cast': 'off',
109
+ 'no-multiple-empty-lines': 'error',
110
+ 'no-new-wrappers': 'error',
111
+ 'no-restricted-imports': [
112
+ 'error',
113
+ { paths: ['rxjs/Rx', 'primeng/primeng', 'primeng'] },
114
+ ],
115
+ 'no-return-assign': 'error',
116
+ 'no-throw-literal': 'error',
117
+ 'no-undef-init': 'error',
118
+ 'no-useless-concat': 'error',
119
+ 'object-shorthand': ['error', 'always', { avoidQuotes: true }],
120
+ 'one-var': ['error', 'never'],
121
+ 'prefer-arrow-callback': 'error',
122
+ 'prefer-template': 'error',
123
+ radix: 'error',
124
+
125
+ // ── rxjs ─────────────────────────────────────────────────────────
126
+ 'rxjs/no-implicit-any-catch': ['error', { allowExplicitAny: true }],
127
+ 'rxjs/no-sharereplay': 'off',
128
+
129
+ // ── import-x ─────────────────────────────────────────────────────
130
+ 'import-x/no-cycle': ['warn', { maxDepth: 3, ignoreExternal: true }],
131
+ 'import-x/no-deprecated': 'warn',
132
+ 'import-x/first': 'error',
133
+ 'import-x/newline-after-import': 'error',
134
+ 'import-x/no-duplicates': 'error',
135
+
136
+ // ── simple-import-sort ───────────────────────────────────────────
137
+ 'simple-import-sort/imports': 'error',
138
+ 'simple-import-sort/exports': 'error',
139
+ },
140
+ },
141
+ ];
142
+ export default tsRecommended;
@@ -0,0 +1,14 @@
1
+ import tsBase from './ts-base.mjs';
2
+
3
+ // Only the rules written by this library. `tsRecommended` includes it, alongside the
4
+ // house style; use this layer directly to opt out of the latter.
5
+ const tsRules = (plugin) => [
6
+ tsBase(plugin),
7
+ {
8
+ name: 'prestations-ng/ts-rules',
9
+ rules: {
10
+ '@dsivd/prestations-ng/no-direct-signal-mutation': 'error',
11
+ },
12
+ },
13
+ ];
14
+ export default tsRules;
@@ -1,7 +1,22 @@
1
- import { noDirectSignalMutation } from './rules/index.mjs';
1
+ import templateRecommended from './configs/template-recommended.mjs';
2
+ import templateRules from './configs/template-rules.mjs';
3
+ import tsRecommended from './configs/ts-recommended.mjs';
4
+ import tsRules from './configs/ts-rules.mjs';
5
+ import rules from './rules/index.mjs';
2
6
 
3
- export default {
4
- rules: {
5
- 'no-direct-signal-mutation': noDirectSignalMutation,
6
- },
7
+ const plugin = {
8
+ meta: { name: '@dsivd/prestations-ng' },
9
+ rules,
7
10
  };
11
+
12
+ const configs = {
13
+ // The house presets: baselines, this library's rules, and the shared conventions.
14
+ tsRecommended: tsRecommended(plugin),
15
+ templateRecommended: templateRecommended(plugin),
16
+ // Only this library's own rules, for a project that supplies its own style.
17
+ tsRules: tsRules(plugin),
18
+ templateRules: templateRules(plugin),
19
+ };
20
+
21
+ export { configs, plugin, rules };
22
+ export default { configs, plugin, rules };
@@ -1,3 +1,4 @@
1
+ import * as templateParser from '@angular-eslint/template-parser';
1
2
  import { describe, it } from 'vitest';
2
3
  import { RuleTester } from 'eslint';
3
4
  import rule from '../no-direct-signal-mutation.mjs';
@@ -46,13 +47,13 @@ describe('no-direct-signal-mutation', () => {
46
47
  'this.pendingFilesByFormKey.get(formKey).url = baseUrl',
47
48
  'const autocompleteComponent = viewChild("auto"); autocompleteComponent().inputElement().nativeElement.hidden = true',
48
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',
49
54
 
50
55
  // Method chaining without assignment
51
56
  'model().update((v) => v).subscribe()',
52
-
53
- // One-way bindings are OK
54
- '[model]="model().property"',
55
- '[ngModel]="model().comment"',
56
57
  ],
57
58
 
58
59
  invalid: [
@@ -95,4 +96,85 @@ describe('no-direct-signal-mutation', () => {
95
96
  ],
96
97
  });
97
98
  });
99
+
100
+ const templateRuleTester = new RuleTester({
101
+ languageOptions: { parser: templateParser },
102
+ });
103
+
104
+ it('should validate signal mutation rule in templates', () => {
105
+ templateRuleTester.run('no-direct-signal-mutation', rule, {
106
+ valid: [
107
+ // 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>',
112
+
113
+ // Going through the signal API
114
+ '<button (click)="model.set({ property: value })"></button>',
115
+ '<button (click)="model.update((v) => ({ ...v, property: value }))"></button>',
116
+
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>',
121
+
122
+ // 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>',
132
+ // An output named `…Change` that carries no assignment
133
+ '<app-child (valueChange)="model.set($event)"></app-child>',
134
+ ],
135
+
136
+ invalid: [
137
+ {
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' }],
152
+ },
153
+ // Every compound assignment accepted by the Angular parser
154
+ {
155
+ code: '<button (click)="model().property += value"></button>',
156
+ errors: [{ messageId: 'directMutation' }],
157
+ },
158
+ {
159
+ code: '<button (click)="model().property ??= value"></button>',
160
+ errors: [{ messageId: 'directMutation' }],
161
+ },
162
+ // The mutation hidden in a two-way binding, reported once and not twice
163
+ {
164
+ code: '<input [(ngModel)]="model().comment" />',
165
+ errors: [{ messageId: 'directMutation' }],
166
+ },
167
+ {
168
+ code: '<app-child [(value)]="model().nested.property"></app-child>',
169
+ errors: [{ messageId: 'directMutation' }],
170
+ },
171
+ // An explicit `…Change` handler carrying the assignment: the Binary reports it,
172
+ // the BoundEvent must not report it a second time.
173
+ {
174
+ code: '<app-child (valueChange)="model().property = $event"></app-child>',
175
+ errors: [{ messageId: 'directMutation' }],
176
+ },
177
+ ],
178
+ });
179
+ });
98
180
  });