@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.
- package/CHANGELOG.md +31 -0
- package/CONTRIBUTING.md +25 -0
- package/ESLINT_PLUGIN.md +198 -0
- package/README.md +3 -0
- package/UPGRADING_V19.md +38 -143
- package/dsivd-prestations-ng-19.0.7.tgz +0 -0
- package/eslint/configs/template-base.mjs +10 -0
- package/eslint/configs/template-recommended.mjs +24 -0
- package/eslint/configs/template-rules.mjs +17 -0
- package/eslint/configs/ts-base.mjs +10 -0
- package/eslint/configs/ts-recommended.mjs +142 -0
- package/eslint/configs/ts-rules.mjs +14 -0
- package/eslint/index.mjs +20 -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 +135 -0
- package/src/eslint/configs/template-base.mjs +10 -0
- package/src/eslint/configs/template-recommended.mjs +24 -0
- package/src/eslint/configs/template-rules.mjs +17 -0
- package/src/eslint/configs/ts-base.mjs +10 -0
- package/src/eslint/configs/ts-recommended.mjs +142 -0
- package/src/eslint/configs/ts-rules.mjs +14 -0
- package/src/eslint/index.mjs +20 -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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Collects the signal member names of a component, walking up its inheritance chain.
|
|
7
|
+
*
|
|
8
|
+
* Base classes are resolved automatically: the `extends` clause is matched against the
|
|
9
|
+
* imports of the file, the module is resolved the way TypeScript would (relative paths,
|
|
10
|
+
* `node_modules`, `paths` mappings) and the base class is read from there. Nothing has
|
|
11
|
+
* to be declared by hand.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// `output()` is absent: an output is never called, it is emitted.
|
|
15
|
+
const SIGNAL_FACTORIES = new Set([
|
|
16
|
+
"input",
|
|
17
|
+
"model",
|
|
18
|
+
"computed",
|
|
19
|
+
"signal",
|
|
20
|
+
"linkedSignal",
|
|
21
|
+
"toSignal",
|
|
22
|
+
"viewChild",
|
|
23
|
+
"viewChildren",
|
|
24
|
+
"contentChild",
|
|
25
|
+
"contentChildren",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Types of a signal member. Declaration files qualify them with the namespace they were
|
|
29
|
+
// imported under: `readonly icon: _angular_core.ModelSignal<IconProp>`, hence the
|
|
30
|
+
// comparison on the last segment only.
|
|
31
|
+
const SIGNAL_TYPES = new Set([
|
|
32
|
+
"Signal",
|
|
33
|
+
"WritableSignal",
|
|
34
|
+
"InputSignal",
|
|
35
|
+
"InputSignalWithTransform",
|
|
36
|
+
"ModelSignal",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const parsedFiles = new Map();
|
|
40
|
+
const compilerOptionsByDirectory = new Map();
|
|
41
|
+
|
|
42
|
+
const parseFile = (path) => {
|
|
43
|
+
let stamp;
|
|
44
|
+
try {
|
|
45
|
+
stamp = statSync(path).mtimeMs;
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cached = parsedFiles.get(path);
|
|
51
|
+
if (cached?.stamp === stamp) {
|
|
52
|
+
return cached.sourceFile;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sourceFile = ts.createSourceFile(
|
|
56
|
+
path,
|
|
57
|
+
readFileSync(path, "utf8"),
|
|
58
|
+
ts.ScriptTarget.Latest,
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
parsedFiles.set(path, { stamp, sourceFile });
|
|
62
|
+
return sourceFile;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const compilerOptionsFor = (path) => {
|
|
66
|
+
const directory = dirname(path);
|
|
67
|
+
if (compilerOptionsByDirectory.has(directory)) {
|
|
68
|
+
return compilerOptionsByDirectory.get(directory);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const configPath = ts.findConfigFile(directory, ts.sys.fileExists);
|
|
72
|
+
const config = configPath
|
|
73
|
+
? ts.readConfigFile(configPath, ts.sys.readFile).config
|
|
74
|
+
: undefined;
|
|
75
|
+
// Only the options are converted: reading the whole config would glob `include`.
|
|
76
|
+
const { options } = ts.convertCompilerOptionsFromJson(
|
|
77
|
+
config?.compilerOptions ?? {},
|
|
78
|
+
configPath ? dirname(configPath) : directory,
|
|
79
|
+
);
|
|
80
|
+
// Without these, `node_modules` is not searched at all.
|
|
81
|
+
options.module ??= ts.ModuleKind.ESNext;
|
|
82
|
+
options.moduleResolution ??= ts.ModuleResolutionKind.Bundler;
|
|
83
|
+
|
|
84
|
+
compilerOptionsByDirectory.set(directory, options);
|
|
85
|
+
return options;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const resolveModule = (specifier, containingFile) =>
|
|
89
|
+
ts.resolveModuleName(
|
|
90
|
+
specifier,
|
|
91
|
+
containingFile,
|
|
92
|
+
compilerOptionsFor(containingFile),
|
|
93
|
+
ts.sys,
|
|
94
|
+
).resolvedModule?.resolvedFileName;
|
|
95
|
+
|
|
96
|
+
const lastSegmentOf = (entityName) =>
|
|
97
|
+
ts.isQualifiedName(entityName) ? entityName.right.text : entityName.text;
|
|
98
|
+
|
|
99
|
+
const isSignalType = (typeNode) =>
|
|
100
|
+
!!typeNode &&
|
|
101
|
+
ts.isTypeReferenceNode(typeNode) &&
|
|
102
|
+
SIGNAL_TYPES.has(lastSegmentOf(typeNode.typeName));
|
|
103
|
+
|
|
104
|
+
const isSignalFactoryCall = (expression) => {
|
|
105
|
+
if (!expression || !ts.isCallExpression(expression)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
// `input.required<T>()` and `viewChild.required(…)` unwrap to their factory.
|
|
109
|
+
let callee = expression.expression;
|
|
110
|
+
while (ts.isPropertyAccessExpression(callee)) {
|
|
111
|
+
callee = callee.expression;
|
|
112
|
+
}
|
|
113
|
+
return ts.isIdentifier(callee) && SIGNAL_FACTORIES.has(callee.text);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const isSignalMember = (member) => {
|
|
117
|
+
if (ts.isGetAccessor(member)) {
|
|
118
|
+
return isSignalType(member.type);
|
|
119
|
+
}
|
|
120
|
+
return (
|
|
121
|
+
ts.isPropertyDeclaration(member) &&
|
|
122
|
+
(isSignalType(member.type) || isSignalFactoryCall(member.initializer))
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const findClass = (sourceFile, className) => {
|
|
127
|
+
let found;
|
|
128
|
+
const visit = (node) => {
|
|
129
|
+
if (found) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (ts.isClassDeclaration(node) && node.name?.text === className) {
|
|
133
|
+
found = node;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
node.forEachChild(visit);
|
|
137
|
+
};
|
|
138
|
+
sourceFile.forEachChild(visit);
|
|
139
|
+
return found;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const extendedClassNameOf = (classDeclaration) => {
|
|
143
|
+
const clause = classDeclaration.heritageClauses?.find(
|
|
144
|
+
(heritageClause) => heritageClause.token === ts.SyntaxKind.ExtendsKeyword,
|
|
145
|
+
);
|
|
146
|
+
const expression = clause?.types[0]?.expression;
|
|
147
|
+
return expression && ts.isIdentifier(expression) ? expression.text : undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// The import that brings `localName` into the file, with the name it is exported under.
|
|
151
|
+
const importOf = (sourceFile, localName) => {
|
|
152
|
+
for (const statement of sourceFile.statements) {
|
|
153
|
+
const bindings = ts.isImportDeclaration(statement)
|
|
154
|
+
? statement.importClause?.namedBindings
|
|
155
|
+
: undefined;
|
|
156
|
+
if (!bindings || !ts.isNamedImports(bindings)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const element = bindings.elements.find(
|
|
160
|
+
(namedImport) => namedImport.name.text === localName,
|
|
161
|
+
);
|
|
162
|
+
if (element) {
|
|
163
|
+
return {
|
|
164
|
+
specifier: statement.moduleSpecifier.text,
|
|
165
|
+
exportedName: (element.propertyName ?? element.name).text,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const reExportedModulesOf = (sourceFile) =>
|
|
173
|
+
sourceFile.statements
|
|
174
|
+
.filter((statement) => ts.isExportDeclaration(statement) && statement.moduleSpecifier)
|
|
175
|
+
.map((statement) => statement.moduleSpecifier.text);
|
|
176
|
+
|
|
177
|
+
const collect = (path, className, names, visited) => {
|
|
178
|
+
const key = `${path}::${className ?? "*"}`;
|
|
179
|
+
if (visited.has(key)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
visited.add(key);
|
|
183
|
+
|
|
184
|
+
const sourceFile = parseFile(path);
|
|
185
|
+
if (!sourceFile) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const classDeclarations = className
|
|
190
|
+
? [findClass(sourceFile, className)].filter(Boolean)
|
|
191
|
+
: sourceFile.statements.filter(ts.isClassDeclaration);
|
|
192
|
+
|
|
193
|
+
// A barrel: the class is only re-exported from here.
|
|
194
|
+
if (className && classDeclarations.length === 0) {
|
|
195
|
+
for (const specifier of reExportedModulesOf(sourceFile)) {
|
|
196
|
+
const target = resolveModule(specifier, path);
|
|
197
|
+
if (target) {
|
|
198
|
+
collect(target, className, names, visited);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const classDeclaration of classDeclarations) {
|
|
205
|
+
for (const member of classDeclaration.members) {
|
|
206
|
+
if (member.name && ts.isIdentifier(member.name) && isSignalMember(member)) {
|
|
207
|
+
names.add(member.name.text);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const baseName = extendedClassNameOf(classDeclaration);
|
|
212
|
+
if (!baseName) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Declaration bundles are flattened: the base class often sits in the same file.
|
|
216
|
+
if (findClass(sourceFile, baseName)) {
|
|
217
|
+
collect(path, baseName, names, visited);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const imported = importOf(sourceFile, baseName);
|
|
221
|
+
const target = imported && resolveModule(imported.specifier, path);
|
|
222
|
+
if (target) {
|
|
223
|
+
collect(target, imported.exportedName, names, visited);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const collectSignalNames = (componentPath) => {
|
|
229
|
+
const names = new Set();
|
|
230
|
+
collect(componentPath, undefined, names, new Set());
|
|
231
|
+
return names;
|
|
232
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two subtleties of the Angular template AST that every template rule runs into.
|
|
3
|
+
* Both are easy to get wrong, hence the shared names.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The expression carried by a `BoundEvent` handler.
|
|
8
|
+
*
|
|
9
|
+
* A two-way binding `[(x)]="expr"` generates a `BoundEvent` named `xChange` whose
|
|
10
|
+
* handler is the very expression of the twin `BoundAttribute` — same source span, two
|
|
11
|
+
* AST nodes. A rule that reports on both copies reports the binding twice.
|
|
12
|
+
*/
|
|
13
|
+
export const handlerExpressionOf = (boundEvent) => {
|
|
14
|
+
const { handler } = boundEvent;
|
|
15
|
+
return handler?.type === "ASTWithSource" ? handler.ast : handler;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a node is read directly on the component.
|
|
20
|
+
*
|
|
21
|
+
* `mySignal` reads the member on an `ImplicitReceiver`, `this.mySignal` on a
|
|
22
|
+
* `ThisReceiver` — two distinct node types for the same intent. Anything else
|
|
23
|
+
* (`obj.mySignal`) is read on a foreign object.
|
|
24
|
+
*/
|
|
25
|
+
export const isComponentReceiver = (node) =>
|
|
26
|
+
node?.type === "ImplicitReceiver" || node?.type === "ThisReceiver";
|
|
@@ -3153,8 +3153,6 @@ declare class FoehnListSummaryComponent extends FoehnInputComponent<FoehnListIte
|
|
|
3153
3153
|
readonly showAsTable: _angular_core.InputSignal<boolean>;
|
|
3154
3154
|
readonly tableActionButtons: _angular_core.Signal<TemplateRef<unknown>>;
|
|
3155
3155
|
readonly canAddItems: _angular_core.InputSignal<boolean>;
|
|
3156
|
-
readonly internalList: _angular_core.WritableSignal<FoehnListItem[]>;
|
|
3157
|
-
readonly internalListItemDescriptions: _angular_core.WritableSignal<FoehnListItemDescription[]>;
|
|
3158
3156
|
readonly itemRemoved: _angular_core.OutputEmitterRef<void>;
|
|
3159
3157
|
readonly itemEdit: _angular_core.OutputEmitterRef<number>;
|
|
3160
3158
|
listCopyForTable: FoehnListItem[];
|
|
Binary file
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'vitest';
|
|
2
|
-
import { RuleTester } from 'eslint';
|
|
3
|
-
import rule from '../no-direct-signal-mutation.mjs';
|
|
4
|
-
|
|
5
|
-
describe('no-direct-signal-mutation', () => {
|
|
6
|
-
const ruleTester = new RuleTester({
|
|
7
|
-
languageOptions: {
|
|
8
|
-
ecmaVersion: 2020,
|
|
9
|
-
sourceType: 'module',
|
|
10
|
-
},
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('should validate signal mutation rule', () => {
|
|
14
|
-
ruleTester.run('no-direct-signal-mutation', rule, {
|
|
15
|
-
valid: [
|
|
16
|
-
// Using .update()
|
|
17
|
-
'model.update((current) => ({ ...current, property: value }))',
|
|
18
|
-
'this.model.update((current) => ({ ...current, property: value }))',
|
|
19
|
-
'getSignal().update((current) => ({ ...current, property: value }))',
|
|
20
|
-
|
|
21
|
-
// Using .set()
|
|
22
|
-
'model.set({ property: value })',
|
|
23
|
-
'this.model.set({ property: value })',
|
|
24
|
-
'getSignal().set({ property: value })',
|
|
25
|
-
|
|
26
|
-
// Regular property assignments
|
|
27
|
-
'this.property = value',
|
|
28
|
-
'obj.property = value',
|
|
29
|
-
'this.obj.property = value',
|
|
30
|
-
|
|
31
|
-
// Reading from function calls (no assignment)
|
|
32
|
-
'model().property',
|
|
33
|
-
'this.model().property',
|
|
34
|
-
'getSignal().property',
|
|
35
|
-
|
|
36
|
-
// Method calls on function results
|
|
37
|
-
'model().someMethod()',
|
|
38
|
-
'this.model().someMethod()',
|
|
39
|
-
'getSignal().doSomething()',
|
|
40
|
-
|
|
41
|
-
// Assignments to non-function results
|
|
42
|
-
'let x = value',
|
|
43
|
-
'const obj = { prop: value }',
|
|
44
|
-
'array[0] = value',
|
|
45
|
-
'pendingFilesByFormKey.get(formKey).files = []',
|
|
46
|
-
'this.pendingFilesByFormKey.get(formKey).url = baseUrl',
|
|
47
|
-
'const autocompleteComponent = viewChild("auto"); autocompleteComponent().inputElement().nativeElement.hidden = true',
|
|
48
|
-
'component.inputElement().nativeElement.value = ""',
|
|
49
|
-
|
|
50
|
-
// Method chaining without assignment
|
|
51
|
-
'model().update((v) => v).subscribe()',
|
|
52
|
-
|
|
53
|
-
// One-way bindings are OK
|
|
54
|
-
'[model]="model().property"',
|
|
55
|
-
'[ngModel]="model().comment"',
|
|
56
|
-
],
|
|
57
|
-
|
|
58
|
-
invalid: [
|
|
59
|
-
{
|
|
60
|
-
code: "model().property = value",
|
|
61
|
-
errors: [{ messageId: 'directMutation' }],
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
code: "this.model().property = value",
|
|
65
|
-
errors: [{ messageId: 'directMutation' }],
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
code: "getSignal().property = value",
|
|
69
|
-
errors: [{ messageId: 'directMutation' }],
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
code: "model().nested.property = value",
|
|
73
|
-
errors: [{ messageId: 'directMutation' }],
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
code: "this.model().deeply.nested.property = value",
|
|
77
|
-
errors: [{ messageId: 'directMutation' }],
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
code: "model().property += value",
|
|
81
|
-
errors: [{ messageId: 'directMutation' }],
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
code: "model().property -= value",
|
|
85
|
-
errors: [{ messageId: 'directMutation' }],
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
code: "model().property *= value",
|
|
89
|
-
errors: [{ messageId: 'directMutation' }],
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
code: "model().array[0] = value",
|
|
93
|
-
errors: [{ messageId: 'directMutation' }],
|
|
94
|
-
},
|
|
95
|
-
],
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
});
|