@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.
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +24 -0
- package/ESLINT_PLUGIN.md +177 -0
- package/README.md +3 -0
- package/UPGRADING_V19.md +24 -6
- package/dsivd-prestations-ng-19.0.6.tgz +0 -0
- package/eslint/configs/template-base.mjs +10 -0
- package/eslint/configs/template-recommended.mjs +15 -0
- package/eslint/configs/ts-base.mjs +10 -0
- package/eslint/configs/ts-recommended.mjs +12 -0
- package/eslint/index.mjs +14 -5
- package/eslint/rules/index.mjs +7 -1
- package/eslint/rules/no-direct-signal-mutation.mjs +240 -136
- package/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
- package/eslint/signal-names.mjs +232 -0
- package/eslint/template-ast.mjs +26 -0
- package/fesm2022/dsivd-prestations-ng.mjs +7 -9
- package/fesm2022/dsivd-prestations-ng.mjs.map +1 -1
- package/package.json +1 -1
- package/src/eslint/configs/__tests__/configs.test.mjs +70 -0
- package/src/eslint/configs/template-base.mjs +10 -0
- package/src/eslint/configs/template-recommended.mjs +15 -0
- package/src/eslint/configs/ts-base.mjs +10 -0
- package/src/eslint/configs/ts-recommended.mjs +12 -0
- package/src/eslint/index.mjs +14 -5
- package/src/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +86 -4
- package/src/eslint/rules/__tests__/no-uninvoked-signal-in-template.test.mjs +291 -0
- package/src/eslint/rules/index.mjs +7 -1
- package/src/eslint/rules/no-direct-signal-mutation.mjs +240 -136
- package/src/eslint/rules/no-uninvoked-signal-in-template.mjs +170 -0
- package/src/eslint/signal-names.mjs +232 -0
- package/src/eslint/template-ast.mjs +26 -0
- package/types/dsivd-prestations-ng.d.ts +0 -2
- package/dsivd-prestations-ng-19.0.6-beta.2.tgz +0 -0
- package/eslint/rules/__tests__/no-direct-signal-mutation.test.mjs +0 -98
package/package.json
CHANGED
|
@@ -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;
|
package/src/eslint/index.mjs
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 = """></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
|
-
|
|
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
|
+
};
|