@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,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;
|
package/eslint/index.mjs
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 };
|
package/eslint/rules/index.mjs
CHANGED
|
@@ -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
|
+
};
|
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
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
|
|
38
|
-
if (
|
|
39
|
-
|
|
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.
|
|
130
|
-
node.
|
|
131
|
-
isViewQueryFactoryCall(node.value)
|
|
259
|
+
TEMPLATE_ASSIGNMENTS.has(node.operation) &&
|
|
260
|
+
isTemplateCallResultMember(node.left)
|
|
132
261
|
) {
|
|
133
|
-
|
|
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
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
165
|
-
if (
|
|
166
|
-
|
|
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
|
};
|