@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
@@ -1,8 +1,222 @@
1
+ import { handlerExpressionOf, isComponentReceiver } from "../template-ast.mjs";
2
+
1
3
  /**
2
4
  * ESLint rule to detect direct mutations of signal/getter results.
3
5
  * Warns against patterns like: model().property = value
4
6
  * Suggests using .update() or .set() instead.
7
+ *
8
+ * Applies to TypeScript files and to Angular templates, where the mutation is also
9
+ * hidden in every two-way binding on a call result: [(ngModel)]="model().property".
5
10
  */
11
+
12
+ // Assignment operators accepted by the Angular expression parser.
13
+ const TEMPLATE_ASSIGNMENTS = new Set([
14
+ "=",
15
+ "+=",
16
+ "-=",
17
+ "*=",
18
+ "/=",
19
+ "%=",
20
+ "**=",
21
+ "&&=",
22
+ "||=",
23
+ "??=",
24
+ ]);
25
+
26
+ const TEMPLATE_MEMBER_READS = new Set([
27
+ "PropertyRead",
28
+ "SafePropertyRead",
29
+ "KeyedRead",
30
+ "SafeKeyedRead",
31
+ ]);
32
+
33
+ const VIEW_QUERY_FACTORIES = new Set([
34
+ "viewChild",
35
+ "viewChildren",
36
+ "contentChild",
37
+ "contentChildren",
38
+ ]);
39
+
40
+ // ── TypeScript ───────────────────────────────────────────────────────────────
41
+
42
+ function getCalledName(callee) {
43
+ if (!callee) {
44
+ return null;
45
+ }
46
+ if (callee.type === "Identifier") {
47
+ return callee.name;
48
+ }
49
+ if (
50
+ callee.type === "MemberExpression" &&
51
+ !callee.computed &&
52
+ callee.property.type === "Identifier"
53
+ ) {
54
+ return callee.property.name;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Finds the first/root function call used as the base of a mutation chain.
61
+ * Examples:
62
+ * - model().property = x => model()
63
+ * - this.model().deep.prop = x => this.model()
64
+ * - this.query().el().x = y => this.query()
65
+ */
66
+ function getFirstCallExpression(node) {
67
+ if (!node) {
68
+ return null;
69
+ }
70
+ if (node.type === "MemberExpression") {
71
+ return getFirstCallExpression(node.object);
72
+ }
73
+ if (node.type === "CallExpression") {
74
+ if (node.callee.type === "MemberExpression") {
75
+ const nestedCall = getFirstCallExpression(node.callee.object);
76
+ return nestedCall || node;
77
+ }
78
+ return node;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function isViewQueryFactoryCall(node) {
84
+ if (node?.type !== "CallExpression") {
85
+ return false;
86
+ }
87
+ // `viewChild.required(…)` unwraps to its factory.
88
+ let callee = node.callee;
89
+ while (callee?.type === "MemberExpression") {
90
+ callee = callee.object;
91
+ }
92
+ return callee?.type === "Identifier" && VIEW_QUERY_FACTORIES.has(callee.name);
93
+ }
94
+
95
+ function walkAst(node, visitor, visited = new WeakSet()) {
96
+ if (!node || typeof node !== "object") {
97
+ return;
98
+ }
99
+ if (visited.has(node)) {
100
+ return;
101
+ }
102
+ visited.add(node);
103
+
104
+ visitor(node);
105
+
106
+ for (const key of Object.keys(node)) {
107
+ // ESLint AST nodes include cyclic parent references.
108
+ if (key === "parent") {
109
+ continue;
110
+ }
111
+ const child = node[key];
112
+ if (Array.isArray(child)) {
113
+ for (const element of child) {
114
+ walkAst(element, visitor, visited);
115
+ }
116
+ } else {
117
+ walkAst(child, visitor, visited);
118
+ }
119
+ }
120
+ }
121
+
122
+ // A view query signal is read-only: `.set()` does not exist on it, so a mutation
123
+ // reached through one must not be reported.
124
+ function collectViewQueryAccessorNames(ast) {
125
+ const names = new Set();
126
+
127
+ walkAst(ast, (node) => {
128
+ if (
129
+ node.type === "VariableDeclarator" &&
130
+ node.id?.type === "Identifier" &&
131
+ isViewQueryFactoryCall(node.init)
132
+ ) {
133
+ names.add(node.id.name);
134
+ return;
135
+ }
136
+
137
+ if (
138
+ (node.type === "PropertyDefinition" || node.type === "ClassProperty") &&
139
+ node.key?.type === "Identifier" &&
140
+ isViewQueryFactoryCall(node.value)
141
+ ) {
142
+ names.add(node.key.name);
143
+ }
144
+ });
145
+
146
+ return names;
147
+ }
148
+
149
+ function isViewQueryAccessorCall(callee, viewQueryAccessorNames) {
150
+ const calledName = getCalledName(callee);
151
+ return calledName ? viewQueryAccessorNames.has(calledName) : false;
152
+ }
153
+
154
+ function isSignalLikeRootCall(node, viewQueryAccessorNames) {
155
+ if (node.arguments.length !== 0) {
156
+ return false;
157
+ }
158
+
159
+ // model().prop = ... (local signal-like accessor)
160
+ if (node.callee.type === "Identifier") {
161
+ return !viewQueryAccessorNames.has(node.callee.name);
162
+ }
163
+
164
+ // this.model().prop = ... (component/service signal accessor)
165
+ // component.model().prop = ... should not be assumed to be a signal read.
166
+ if (node.callee.type === "MemberExpression") {
167
+ if (node.callee.object?.type !== "ThisExpression") {
168
+ return false;
169
+ }
170
+ return !isViewQueryAccessorCall(node.callee, viewQueryAccessorNames);
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ // ── Angular templates ────────────────────────────────────────────────────────
177
+
178
+ // Template counterpart of `getFirstCallExpression`.
179
+ function getFirstTemplateCall(node) {
180
+ if (!node) {
181
+ return null;
182
+ }
183
+ if (TEMPLATE_MEMBER_READS.has(node.type)) {
184
+ return getFirstTemplateCall(node.receiver);
185
+ }
186
+ if (node.type === "Call" || node.type === "SafeCall") {
187
+ return getFirstTemplateCall(node.receiver) || node;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Template counterpart of `isSignalLikeRootCall`. A template has no bare identifier:
194
+ * `model()` and `this.model()` both read the member directly on the component, so both
195
+ * collapse to `isComponentReceiver`. `map.get(key)` (arguments) and
196
+ * `component.inputElement()` (foreign receiver) are therefore not assumed to be signal
197
+ * reads, as in TypeScript.
198
+ */
199
+ function isSignalLikeRootTemplateCall(node) {
200
+ if (node.args?.length !== 0) {
201
+ return false;
202
+ }
203
+ const callee = node.receiver;
204
+ if (!callee || !TEMPLATE_MEMBER_READS.has(callee.type)) {
205
+ return false;
206
+ }
207
+ return isComponentReceiver(callee.receiver);
208
+ }
209
+
210
+ // Template counterpart of the TypeScript checks: a member of a signal-like call
211
+ // result, such as `model().property` or `this.model().array[0]`.
212
+ function isTemplateCallResultMember(node) {
213
+ if (!node || !TEMPLATE_MEMBER_READS.has(node.type)) {
214
+ return false;
215
+ }
216
+ const rootCall = getFirstTemplateCall(node.receiver);
217
+ return !!rootCall && isSignalLikeRootTemplateCall(rootCall);
218
+ }
219
+
6
220
  export default {
7
221
  meta: {
8
222
  type: "problem",
@@ -19,157 +233,47 @@ export default {
19
233
  },
20
234
 
21
235
  create(context) {
22
- const sourceCode = context.sourceCode;
23
- const viewQueryFactoryNames = new Set([
24
- "viewChild",
25
- "viewChildren",
26
- "contentChild",
27
- "contentChildren",
28
- ]);
29
- const viewQueryAccessorNames = collectViewQueryAccessorNames(sourceCode.ast);
236
+ const viewQueryAccessorNames = collectViewQueryAccessorNames(
237
+ context.sourceCode.ast,
238
+ );
239
+
240
+ const report = (node) =>
241
+ context.report({ node, messageId: "directMutation" });
30
242
 
31
243
  return {
244
+ // ── TypeScript ───────────────────────────────────────────────────────────
32
245
  AssignmentExpression(node) {
33
246
  if (node.left.type !== "MemberExpression") {
34
247
  return;
35
248
  }
36
249
 
37
- const rootCallExpression = getFirstCallExpression(node.left.object);
38
- if (rootCallExpression && isSignalLikeRootCall(rootCallExpression)) {
39
- context.report({
40
- node,
41
- messageId: "directMutation",
42
- });
250
+ const rootCall = getFirstCallExpression(node.left.object);
251
+ if (rootCall && isSignalLikeRootCall(rootCall, viewQueryAccessorNames)) {
252
+ report(node);
43
253
  }
44
254
  },
45
- };
46
-
47
- function isSignalLikeRootCall(node) {
48
- if (node.arguments.length !== 0) {
49
- return false;
50
- }
51
-
52
- // model().prop = ... (local signal-like accessor)
53
- if (node.callee.type === "Identifier") {
54
- return !viewQueryAccessorNames.has(node.callee.name);
55
- }
56
-
57
- // this.model().prop = ... (component/service signal accessor)
58
- // component.model().prop = ... should not be assumed to be a signal read.
59
- if (node.callee.type === "MemberExpression") {
60
- if (node.callee.object?.type !== "ThisExpression") {
61
- return false;
62
- }
63
- return !isViewQueryAccessorCall(node.callee);
64
- }
65
-
66
- return false;
67
- }
68
-
69
- function isViewQueryAccessorCall(callee) {
70
- const calledName = getCalledName(callee);
71
- return calledName ? viewQueryAccessorNames.has(calledName) : false;
72
- }
73
-
74
- function getCalledName(callee) {
75
- if (!callee) {
76
- return null;
77
- }
78
- if (callee.type === "Identifier") {
79
- return callee.name;
80
- }
81
- if (
82
- callee.type === "MemberExpression" &&
83
- !callee.computed &&
84
- callee.property.type === "Identifier"
85
- ) {
86
- return callee.property.name;
87
- }
88
- return null;
89
- }
90
-
91
- /**
92
- * Finds the first/root function call used as the base of a mutation chain.
93
- * Examples:
94
- * - model().property = x => model()
95
- * - this.model().deep.prop = x => this.model()
96
- * - this.query().el().x = y => this.query()
97
- */
98
- function getFirstCallExpression(node) {
99
- if (!node) {
100
- return null;
101
- }
102
- if (node.type === "MemberExpression") {
103
- return getFirstCallExpression(node.object);
104
- }
105
- if (node.type === "CallExpression") {
106
- if (node.callee.type === "MemberExpression") {
107
- const nestedCall = getFirstCallExpression(node.callee.object);
108
- return nestedCall || node;
109
- }
110
- return node;
111
- }
112
- return null;
113
- }
114
-
115
- function collectViewQueryAccessorNames(ast) {
116
- const names = new Set();
117
-
118
- walkAst(ast, (node) => {
119
- if (
120
- node.type === "VariableDeclarator" &&
121
- node.id?.type === "Identifier" &&
122
- isViewQueryFactoryCall(node.init)
123
- ) {
124
- names.add(node.id.name);
125
- return;
126
- }
127
255
 
256
+ // ── Angular templates ────────────────────────────────────────────────────
257
+ Binary(node) {
128
258
  if (
129
- (node.type === "PropertyDefinition" || node.type === "ClassProperty") &&
130
- node.key?.type === "Identifier" &&
131
- isViewQueryFactoryCall(node.value)
259
+ TEMPLATE_ASSIGNMENTS.has(node.operation) &&
260
+ isTemplateCallResultMember(node.left)
132
261
  ) {
133
- names.add(node.key.name);
262
+ report(node);
134
263
  }
135
- });
136
-
137
- return names;
138
- }
139
-
140
- function isViewQueryFactoryCall(node) {
141
- return (
142
- node?.type === "CallExpression" &&
143
- node.callee?.type === "Identifier" &&
144
- viewQueryFactoryNames.has(node.callee.name)
145
- );
146
- }
147
-
148
- function walkAst(node, visitor, visited = new WeakSet()) {
149
- if (!node || typeof node !== "object") {
150
- return;
151
- }
152
- if (visited.has(node)) {
153
- return;
154
- }
155
- visited.add(node);
156
-
157
- visitor(node);
264
+ },
158
265
 
159
- for (const key of Object.keys(node)) {
160
- // ESLint AST nodes include cyclic parent references.
161
- if (key === "parent") {
162
- continue;
266
+ BoundEvent(node) {
267
+ // `[(x)]="model().property"` makes Angular assign into the value of the signal.
268
+ // Reporting on the BoundEvent handler reports the binding exactly once.
269
+ if (!node.name?.endsWith("Change")) {
270
+ return;
163
271
  }
164
- const child = node[key];
165
- if (Array.isArray(child)) {
166
- for (const element of child) {
167
- walkAst(element, visitor, visited);
168
- }
169
- } else {
170
- walkAst(child, visitor, visited);
272
+ const handler = handlerExpressionOf(node);
273
+ if (isTemplateCallResultMember(handler)) {
274
+ report(handler);
171
275
  }
172
- }
173
- }
276
+ },
277
+ };
174
278
  },
175
279
  };
@@ -0,0 +1,170 @@
1
+ import { collectSignalNames } from "../signal-names.mjs";
2
+ import { handlerExpressionOf, isComponentReceiver } from "../template-ast.mjs";
3
+
4
+ /**
5
+ * ESLint rule to detect signals referenced without being called in a template.
6
+ * Warns against patterns like: {{ mySignal }}, !mySignal, mySignal + 'x'
7
+ * Suggests calling the signal instead: mySignal()
8
+ *
9
+ * The compiler only covers part of these cases (NG8109 / NG8117): it lets through
10
+ * the negation (`!mySignal`, always false) and the concatenation (`mySignal + 'x'`,
11
+ * which injects the source code of the function).
12
+ *
13
+ * Signal names are resolved from the template's twin `.ts` and from its inheritance
14
+ * chain, base classes of other packages included. Nothing has to be configured.
15
+ */
16
+
17
+ // `mySignal.set(…)` targets the signal itself, not its value: no parentheses needed.
18
+ const SIGNAL_MEMBERS = new Set(["set", "update", "asReadonly"]);
19
+
20
+ // Implicit template variables: never signals of the component.
21
+ const TEMPLATE_BUILTINS = [
22
+ "$event",
23
+ "$index",
24
+ "$count",
25
+ "$first",
26
+ "$last",
27
+ "$even",
28
+ "$odd",
29
+ "$any",
30
+ "$implicit",
31
+ ];
32
+
33
+ // An inline template is a virtual block whose path is derived from the `.ts`, and whose
34
+ // name ends with `.html` too: `foo.component.ts/1_inline-template-….component.html`.
35
+ // It has to be matched BEFORE the external template, otherwise it resolves to garbage.
36
+ function resolveComponentPath(filename) {
37
+ const inlineMatch = filename.match(/^(.*\.ts)(?:[/\\]|$)/);
38
+ if (inlineMatch) {
39
+ return inlineMatch[1];
40
+ }
41
+ return filename.endsWith(".html")
42
+ ? `${filename.slice(0, -".html".length)}.ts`
43
+ : undefined;
44
+ }
45
+
46
+ export default {
47
+ meta: {
48
+ type: "problem",
49
+ docs: {
50
+ description:
51
+ "Warn on signals referenced without being called in an Angular template",
52
+ category: "Best Practices",
53
+ recommended: true,
54
+ },
55
+ fixable: "code",
56
+ schema: [],
57
+ messages: {
58
+ uninvoked:
59
+ "Signal '{{name}}' is referenced without being called. Use '{{name}}()' instead: a bare reference is the function itself, always truthy and serialized as source code.",
60
+ },
61
+ },
62
+
63
+ create(context) {
64
+ const componentPath = resolveComponentPath(context.filename);
65
+ if (!componentPath) {
66
+ return {};
67
+ }
68
+
69
+ const signalNames = collectSignalNames(componentPath);
70
+ if (signalNames.size === 0) {
71
+ return {};
72
+ }
73
+
74
+ // Names bound by the template (#ref, @for items, @if aliases, @let, let-x): they
75
+ // shadow the class members, so they are never reported.
76
+ const shadowed = new Set(TEMPLATE_BUILTINS);
77
+ // References that already carry parentheses (`x()`) or that address the signal
78
+ // itself (`x.set(…)`).
79
+ const exempt = new Set();
80
+ // Spans already handled, by position. Holds the written positions (lvalue): a
81
+ // writable signal is bound WITHOUT parentheses — `[(x)]="mySignal"` — Angular
82
+ // calling `.set()` itself. A two-way binding also duplicates its expression
83
+ // (BoundAttribute + BoundEvent), so the same set dedupes the reports.
84
+ const handledSpans = new Set();
85
+ const candidates = [];
86
+
87
+ const markHandled = (node) => {
88
+ const { start, end } = node?.sourceSpan ?? {};
89
+ if (typeof start === "number") {
90
+ handledSpans.add(`${start}:${end}`);
91
+ }
92
+ };
93
+
94
+ const rememberDeclarations = (node) => {
95
+ for (const declaration of [
96
+ ...(node.references ?? []),
97
+ ...(node.variables ?? []),
98
+ ]) {
99
+ shadowed.add(declaration.name);
100
+ }
101
+ };
102
+
103
+ return {
104
+ Call: (node) => exempt.add(node.receiver),
105
+ SafeCall: (node) => exempt.add(node.receiver),
106
+ Element: rememberDeclarations,
107
+ Template: rememberDeclarations,
108
+ Content: rememberDeclarations,
109
+ BoundEvent(node) {
110
+ // `[(x)]="expr"` generates a BoundEvent `xChange` whose handler IS the written
111
+ // target. Its span is the one of the twin BoundAttribute expression: marking
112
+ // the span neutralizes both copies at once.
113
+ if (node.name?.endsWith("Change")) {
114
+ markHandled(handlerExpressionOf(node));
115
+ }
116
+ },
117
+ Binary(node) {
118
+ if (node.operation === "=") {
119
+ markHandled(node.left);
120
+ }
121
+ },
122
+ LetDeclaration(node) {
123
+ shadowed.add(node.name);
124
+ },
125
+ IfBlockBranch(node) {
126
+ if (node.expressionAlias) {
127
+ shadowed.add(node.expressionAlias.name);
128
+ }
129
+ },
130
+ ForLoopBlock(node) {
131
+ shadowed.add(node.item.name);
132
+ for (const contextVariable of node.contextVariables ?? []) {
133
+ shadowed.add(contextVariable.name);
134
+ }
135
+ },
136
+ PropertyRead(node) {
137
+ if (SIGNAL_MEMBERS.has(node.name)) {
138
+ exempt.add(node.receiver);
139
+ }
140
+ if (isComponentReceiver(node.receiver) && signalNames.has(node.name)) {
141
+ candidates.push(node);
142
+ }
143
+ },
144
+ "Program:exit"() {
145
+ for (const node of candidates) {
146
+ const { start, end } = node.sourceSpan;
147
+ const span = `${start}:${end}`;
148
+ if (
149
+ exempt.has(node) ||
150
+ shadowed.has(node.name) ||
151
+ handledSpans.has(span)
152
+ ) {
153
+ continue;
154
+ }
155
+ handledSpans.add(span);
156
+
157
+ context.report({
158
+ messageId: "uninvoked",
159
+ data: { name: node.name },
160
+ loc: {
161
+ start: context.sourceCode.getLocFromIndex(start),
162
+ end: context.sourceCode.getLocFromIndex(end),
163
+ },
164
+ fix: (fixer) => fixer.insertTextAfterRange([start, end], "()"),
165
+ });
166
+ }
167
+ },
168
+ };
169
+ },
170
+ };