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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CONTRIBUTING.md +24 -0
  3. package/ESLINT_PLUGIN.md +177 -0
  4. package/README.md +3 -0
  5. package/UPGRADING_V19.md +24 -6
  6. package/dsivd-prestations-ng-19.0.6.tgz +0 -0
  7. package/eslint/configs/template-base.mjs +10 -0
  8. package/eslint/configs/template-recommended.mjs +15 -0
  9. package/eslint/configs/ts-base.mjs +10 -0
  10. package/eslint/configs/ts-recommended.mjs +12 -0
  11. package/eslint/index.mjs +14 -5
  12. package/eslint/rules/index.mjs +7 -1
  13. package/eslint/rules/no-direct-signal-mutation.mjs +240 -136
  14. package/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
  15. package/eslint/signal-names.mjs +232 -0
  16. package/eslint/template-ast.mjs +26 -0
  17. package/fesm2022/dsivd-prestations-ng.mjs +7 -9
  18. package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
  19. package/package.json +1 -1
  20. package/src/eslint/configs/__tests__/configs.test.mjs +70 -0
  21. package/src/eslint/configs/template-base.mjs +10 -0
  22. package/src/eslint/configs/template-recommended.mjs +15 -0
  23. package/src/eslint/configs/ts-base.mjs +10 -0
  24. package/src/eslint/configs/ts-recommended.mjs +12 -0
  25. package/src/eslint/index.mjs +14 -5
  26. package/src/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +86 -4
  27. package/src/eslint/rules/__tests__/no-uninvoked-signal-in-template.test.mjs +291 -0
  28. package/src/eslint/rules/index.mjs +7 -1
  29. package/src/eslint/rules/no-direct-signal-mutation.mjs +240 -136
  30. package/src/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
  31. package/src/eslint/signal-names.mjs +232 -0
  32. package/src/eslint/template-ast.mjs +26 -0
  33. package/types/dsivd-prestations-ng.d.ts +0 -2
  34. package/dsivd-prestations-ng-19.0.6-beta.2.tgz +0 -0
  35. 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.6",
44
44
  "module": "fesm2022/dsivd-prestations-ng.mjs",
45
45
  "typings": "types/dsivd-prestations-ng.d.ts",
46
46
  "exports": {
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import prestationsNg, { configs, plugin, rules } from '../../index.mjs';
4
+
5
+ const PLUGIN_KEY = '@dsivd/prestations-ng';
6
+
7
+ const configNames = Object.keys(configs);
8
+
9
+ // Rule ids enabled by a config, whatever its severity.
10
+ const enabledIdsOf = (config) =>
11
+ config.flatMap((entry) => Object.keys(entry.rules ?? {}));
12
+
13
+ describe('eslint configs', () => {
14
+ it('should expose one config per lint target', () => {
15
+ expect(configNames).toEqual(['tsRecommended', 'templateRecommended']);
16
+ });
17
+
18
+ it.each(configNames)('%s should register the plugin first', (name) => {
19
+ const [base, ...rest] = configs[name];
20
+
21
+ expect(base.plugins).toEqual({ [PLUGIN_KEY]: plugin });
22
+ expect(base.rules).toBeUndefined();
23
+ expect(rest.every((entry) => entry.plugins === undefined)).toBe(true);
24
+ });
25
+
26
+ it.each(configNames)('%s should name every entry', (name) => {
27
+ for (const entry of configs[name]) {
28
+ expect(entry.name).toMatch(/^prestations-ng\//);
29
+ }
30
+ });
31
+
32
+ // A config referencing a rule that does not exist breaks the lint of every
33
+ // consuming project at once, so it must never reach a release.
34
+ it.each(configNames)('%s should only enable existing rules', (name) => {
35
+ for (const id of enabledIdsOf(configs[name])) {
36
+ expect(id.startsWith(`${PLUGIN_KEY}/`)).toBe(true);
37
+ expect(rules).toHaveProperty(id.slice(PLUGIN_KEY.length + 1));
38
+ }
39
+ });
40
+
41
+ it('should enable every rule in at least one config', () => {
42
+ const enabled = new Set(configNames.flatMap((n) => enabledIdsOf(configs[n])));
43
+
44
+ for (const ruleName of Object.keys(rules)) {
45
+ expect(enabled).toContain(`${PLUGIN_KEY}/${ruleName}`);
46
+ }
47
+ });
48
+
49
+ it('should keep the template-only rule out of the TypeScript config', () => {
50
+ expect(enabledIdsOf(configs.tsRecommended)).not.toContain(
51
+ `${PLUGIN_KEY}/no-uninvoked-signal-in-template`,
52
+ );
53
+ });
54
+ });
55
+
56
+ describe('eslint plugin', () => {
57
+ it('should be named after the npm package', () => {
58
+ expect(plugin.meta.name).toBe(PLUGIN_KEY);
59
+ });
60
+
61
+ it('should mirror the named exports on the default export', () => {
62
+ expect(prestationsNg).toEqual({ configs, plugin, rules });
63
+ });
64
+
65
+ it.each(Object.entries(rules))('%s should be a usable rule', (_, rule) => {
66
+ expect(typeof rule.create).toBe('function');
67
+ expect(Object.keys(rule.meta.messages).length).toBeGreaterThan(0);
68
+ expect(rule.meta.type).toBe('problem');
69
+ });
70
+ });
@@ -0,0 +1,10 @@
1
+ // Registers the plugin without enabling any rule. Meant to be used through
2
+ // `templateRecommended`, inside the `extends` of a block that already targets
3
+ // `**/*.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,15 @@
1
+ import templateBase from './template-base.mjs';
2
+
3
+ const templateRecommended = (plugin) => [
4
+ templateBase(plugin),
5
+ {
6
+ name: 'prestations-ng/template-recommended',
7
+ rules: {
8
+ // Also in `tsRecommended`: the rule carries both an ESTree and an Angular
9
+ // template visitor, and each fires on its own kind of file.
10
+ '@dsivd/prestations-ng/no-direct-signal-mutation': 'error',
11
+ '@dsivd/prestations-ng/no-uninvoked-signal-in-template': 'error',
12
+ },
13
+ },
14
+ ];
15
+ export default templateRecommended;
@@ -0,0 +1,10 @@
1
+ // Registers the plugin without enabling any rule. Meant to be used through
2
+ // `tsRecommended`, inside the `extends` of a block that already targets `**/*.ts`
3
+ // 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,12 @@
1
+ import tsBase from './ts-base.mjs';
2
+
3
+ const tsRecommended = (plugin) => [
4
+ tsBase(plugin),
5
+ {
6
+ name: 'prestations-ng/ts-recommended',
7
+ rules: {
8
+ '@dsivd/prestations-ng/no-direct-signal-mutation': 'error',
9
+ },
10
+ },
11
+ ];
12
+ export default tsRecommended;
@@ -1,7 +1,16 @@
1
- import { noDirectSignalMutation } from './rules/index.mjs';
1
+ import templateRecommended from './configs/template-recommended.mjs';
2
+ import tsRecommended from './configs/ts-recommended.mjs';
3
+ import rules from './rules/index.mjs';
2
4
 
3
- export default {
4
- rules: {
5
- 'no-direct-signal-mutation': noDirectSignalMutation,
6
- },
5
+ const plugin = {
6
+ meta: { name: '@dsivd/prestations-ng' },
7
+ rules,
7
8
  };
9
+
10
+ const configs = {
11
+ tsRecommended: tsRecommended(plugin),
12
+ templateRecommended: templateRecommended(plugin),
13
+ };
14
+
15
+ export { configs, plugin, rules };
16
+ 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
  });
@@ -0,0 +1,291 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import * as templateParser from '@angular-eslint/template-parser';
5
+ import { RuleTester } from 'eslint';
6
+ import { beforeAll, describe, it } from 'vitest';
7
+ import rule from '../no-uninvoked-signal-in-template.mjs';
8
+
9
+ describe('no-uninvoked-signal-in-template', () => {
10
+ const ruleTester = new RuleTester({
11
+ languageOptions: { parser: templateParser },
12
+ });
13
+
14
+ // The rule reads the twin `.ts` of the template and walks its inheritance chain, so
15
+ // the fixtures have to exist on disk. They are laid out like a real project — the
16
+ // base class package under `node_modules` — so that module resolution is exercised.
17
+ let templatePath;
18
+ let inlineTemplatePath;
19
+ let heirTemplatePath;
20
+
21
+ const write = (path, content) => {
22
+ mkdirSync(join(path, '..'), { recursive: true });
23
+ writeFileSync(path, content, 'utf8');
24
+ };
25
+
26
+ beforeAll(() => {
27
+ const root = mkdtempSync(join(tmpdir(), 'no-uninvoked-signal-'));
28
+
29
+ // A flattened declaration bundle, as published by ng-packagr: several classes in a
30
+ // single file, signal types qualified by the namespace they were imported under.
31
+ write(
32
+ join(root, 'node_modules', '@fake', 'kit', 'package.json'),
33
+ JSON.stringify({ name: '@fake/kit', types: 'index.d.ts' }),
34
+ );
35
+ write(
36
+ join(root, 'node_modules', '@fake', 'kit', 'index.d.ts'),
37
+ `import * as _angular_core from '@angular/core';
38
+ declare class KitInputComponent {
39
+ readonly label: _angular_core.InputSignal<string>;
40
+ readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
41
+ readonly value: _angular_core.ModelSignal<string>;
42
+ plainInherited: string;
43
+ reset(): void;
44
+ }
45
+ declare class KitFieldComponent extends KitInputComponent {
46
+ readonly fieldId: _angular_core.Signal<string>;
47
+ }
48
+ declare class UnrelatedComponent {
49
+ readonly unrelated: _angular_core.InputSignal<string>;
50
+ }
51
+ export { KitFieldComponent, KitInputComponent, UnrelatedComponent };`,
52
+ );
53
+
54
+ // A base class of the project itself, reached through a relative import.
55
+ write(
56
+ join(root, 'src', 'abstract-page.component.ts'),
57
+ `import { KitFieldComponent } from '@fake/kit';
58
+ export abstract class AbstractPageComponent extends KitFieldComponent {
59
+ readonly foehnFormComponent = viewChild(FoehnFormComponent);
60
+ protected readonly step = signal(0);
61
+ }`,
62
+ );
63
+
64
+ write(
65
+ join(root, 'src', 'sample.component.ts'),
66
+ `import { Component, computed, input, model, signal } from '@angular/core';
67
+ import { AbstractPageComponent } from './abstract-page.component';
68
+
69
+ class LocalBaseComponent extends AbstractPageComponent {
70
+ readonly fromLocalBase = input<string>('');
71
+ }
72
+
73
+ @Component({ selector: 'app-sample', templateUrl: './sample.component.html' })
74
+ export class SampleComponent extends LocalBaseComponent {
75
+ readonly count = signal(0);
76
+ readonly title = input.required<string>();
77
+ readonly comment = model<string>('');
78
+ readonly double = computed(() => this.count() * 2);
79
+ readonly items = computed<string[]>(() => []);
80
+ readonly fromType: Signal<number> = this.double;
81
+ plainField = 'plain';
82
+ helper(): string { return ''; }
83
+ }`,
84
+ );
85
+
86
+ templatePath = join(root, 'src', 'sample.component.html');
87
+ // Virtual block name produced by `angular.processInlineTemplates`.
88
+ inlineTemplatePath = join(
89
+ root,
90
+ 'src',
91
+ 'sample.component.ts',
92
+ '1_inline-template-sample.component.ts-1.component.html',
93
+ );
94
+
95
+ // A component whose only signals come from its base classes.
96
+ write(
97
+ join(root, 'src', 'heir.component.ts'),
98
+ `import { AbstractPageComponent } from './abstract-page.component';
99
+ export class HeirComponent extends AbstractPageComponent {}`,
100
+ );
101
+ heirTemplatePath = join(root, 'src', 'heir.component.html');
102
+ });
103
+
104
+ const template = (code) => ({ code, filename: templatePath });
105
+ const invalidTemplate = (code, output, ...names) => ({
106
+ ...template(code),
107
+ output,
108
+ errors: names.map((name) => ({ messageId: 'uninvoked', data: { name } })),
109
+ });
110
+
111
+ it('should validate uninvoked signal rule', () => {
112
+ ruleTester.run('no-uninvoked-signal-in-template', rule, {
113
+ valid: [
114
+ // Invoked signals
115
+ template('{{ count() }}'),
116
+ template('{{ this.count() }}'),
117
+ template('{{ double() + count() }}'),
118
+ template('<div [hidden]="!count()"></div>'),
119
+ template('@if (double()) { {{ title() }} }'),
120
+
121
+ // Safe call
122
+ template('{{ count?.() }}'),
123
+
124
+ // Members of the signal API target the signal itself
125
+ template('<button (click)="count.set(1)"></button>'),
126
+ template('<button (click)="comment.update((v) => v + \'!\')"></button>'),
127
+ template('<app-child [value]="comment.asReadonly()"></app-child>'),
128
+
129
+ // Non-signal members
130
+ template('{{ plainField }}'),
131
+ template('{{ helper() }}'),
132
+
133
+ // Written position: Angular calls `.set()` itself
134
+ template('<button (click)="comment = \'x\'"></button>'),
135
+
136
+ // Template-local names shadow the class members
137
+ template('<input #count />{{ count.value }}'),
138
+ template('@for (title of items(); track title) { {{ title }} }'),
139
+ template('@if (comment(); as count) { {{ count }} }'),
140
+ template('@let count = 1; {{ count }}'),
141
+ template('<ng-template let-count>{{ count }}</ng-template>'),
142
+ template('@for (item of items(); track item) { {{ $index }} }'),
143
+
144
+ // No twin `.ts`: nothing to resolve
145
+ { code: '{{ count }}', filename: 'unknown.component.html' },
146
+ ],
147
+
148
+ invalid: [
149
+ invalidTemplate('{{ count }}', '{{ count() }}', 'count'),
150
+ invalidTemplate('{{ this.count }}', '{{ this.count() }}', 'count'),
151
+ invalidTemplate(
152
+ '<div [hidden]="!count"></div>',
153
+ '<div [hidden]="!count()"></div>',
154
+ 'count',
155
+ ),
156
+ invalidTemplate(
157
+ '{{ title + \'x\' }}',
158
+ '{{ title() + \'x\' }}',
159
+ 'title',
160
+ ),
161
+ invalidTemplate(
162
+ '@if (double) { {{ count }} }',
163
+ '@if (double()) { {{ count() }} }',
164
+ 'double',
165
+ 'count',
166
+ ),
167
+ invalidTemplate(
168
+ '<div>{{ items.length }}</div>',
169
+ '<div>{{ items().length }}</div>',
170
+ 'items',
171
+ ),
172
+ // Declared by its type only, without a signal factory
173
+ invalidTemplate('{{ fromType }}', '{{ fromType() }}', 'fromType'),
174
+ invalidTemplate(
175
+ '@for (item of items; track item) { {{ item }} }',
176
+ '@for (item of items(); track item) { {{ item }} }',
177
+ 'items',
178
+ ),
179
+ invalidTemplate(
180
+ '<button (click)="doSomething(count)"></button>',
181
+ '<button (click)="doSomething(count())"></button>',
182
+ 'count',
183
+ ),
184
+ ],
185
+ });
186
+ });
187
+
188
+ // A writable signal is two-way bound WITHOUT parentheses: Angular reads it and calls
189
+ // `.set()` itself. Adding `()` there is a compilation error, so the autofix must not
190
+ // touch the target of a `[(…)]` binding.
191
+ it('should not require parentheses on a two-way bound writable signal', () => {
192
+ ruleTester.run('no-uninvoked-signal-in-template', rule, {
193
+ valid: [
194
+ template('<app-child [(value)]="comment"></app-child>'),
195
+ template('<app-child [(value)]="this.comment"></app-child>'),
196
+ template('<input [(ngModel)]="comment" />'),
197
+ // Inherited writable signal as the target
198
+ template('<app-child [(value)]="value"></app-child>'),
199
+ // Two-way binding next to a correctly invoked signal
200
+ template('<app-child [(value)]="comment" [label]="title()"></app-child>'),
201
+ ],
202
+
203
+ invalid: [
204
+ // The exemption is scoped to the bound position, not to the signal name
205
+ invalidTemplate(
206
+ '<app-child [(value)]="comment"></app-child>{{ comment }}',
207
+ '<app-child [(value)]="comment"></app-child>{{ comment() }}',
208
+ 'comment',
209
+ ),
210
+ // A one-way binding on the same element is still reported, exactly once
211
+ invalidTemplate(
212
+ '<app-child [(value)]="comment" [label]="title"></app-child>',
213
+ '<app-child [(value)]="comment" [label]="title()"></app-child>',
214
+ 'title',
215
+ ),
216
+ // `[value]` alone is not a two-way binding
217
+ invalidTemplate(
218
+ '<app-child [value]="comment"></app-child>',
219
+ '<app-child [value]="comment()"></app-child>',
220
+ 'comment',
221
+ ),
222
+ ],
223
+ });
224
+ });
225
+
226
+ // No list of base-class members is configured anywhere: everything below is resolved
227
+ // by following the `extends` clauses across files and packages.
228
+ it('should resolve inherited signals automatically', () => {
229
+ const heir = (code) => ({ code, filename: heirTemplatePath });
230
+ const invalidHeir = (code, output, name) => ({
231
+ ...heir(code),
232
+ output,
233
+ errors: [{ messageId: 'uninvoked', data: { name } }],
234
+ });
235
+
236
+ ruleTester.run('no-uninvoked-signal-in-template', rule, {
237
+ valid: [
238
+ // Relative import, then a package, up the whole chain
239
+ heir('{{ foehnFormComponent()?.id }}'),
240
+ heir('{{ fieldId() }}'),
241
+ heir('<div [class.off]="disabled()">{{ label() }}</div>'),
242
+ template('{{ fromLocalBase() }}'),
243
+
244
+ // Not a signal, even though it sits on the same base class
245
+ heir('{{ plainInherited }}'),
246
+ heir('<button (click)="reset()"></button>'),
247
+
248
+ // A class of the same declaration bundle that is NOT in the chain
249
+ heir('{{ unrelated }}'),
250
+ ],
251
+
252
+ invalid: [
253
+ // Declared in the base class of the twin `.ts`
254
+ invalidHeir(
255
+ '{{ foehnFormComponent }}',
256
+ '{{ foehnFormComponent() }}',
257
+ 'foehnFormComponent',
258
+ ),
259
+ // Declared in a package, reached through two levels of `extends`
260
+ invalidHeir('{{ label }}', '{{ label() }}', 'label'),
261
+ invalidHeir('{{ fieldId }}', '{{ fieldId() }}', 'fieldId'),
262
+ // `InputSignalWithTransform`
263
+ invalidHeir(
264
+ '<div [class.off]="disabled"></div>',
265
+ '<div [class.off]="disabled()"></div>',
266
+ 'disabled',
267
+ ),
268
+ // Base class declared in the component file itself
269
+ invalidTemplate(
270
+ '{{ fromLocalBase }}',
271
+ '{{ fromLocalBase() }}',
272
+ 'fromLocalBase',
273
+ ),
274
+ ],
275
+ });
276
+ });
277
+
278
+ it('should resolve the signals of an inline template', () => {
279
+ ruleTester.run('no-uninvoked-signal-in-template', rule, {
280
+ valid: [{ code: '{{ count() }}', filename: inlineTemplatePath }],
281
+ invalid: [
282
+ {
283
+ code: '{{ count }}',
284
+ filename: inlineTemplatePath,
285
+ output: '{{ count() }}',
286
+ errors: [{ messageId: 'uninvoked', data: { name: 'count' } }],
287
+ },
288
+ ],
289
+ });
290
+ });
291
+ });
@@ -1 +1,7 @@
1
- export { default as noDirectSignalMutation } from './no-direct-signal-mutation.mjs';
1
+ import noDirectSignalMutation from './no-direct-signal-mutation.mjs';
2
+ import noUninvokedSignalInTemplate from './no-uninvoked-signal-in-template.mjs';
3
+
4
+ export default {
5
+ 'no-direct-signal-mutation': noDirectSignalMutation,
6
+ 'no-uninvoked-signal-in-template': noUninvokedSignalInTemplate,
7
+ };