eslint-plugin-power-esrules 0.1.1 → 0.1.3
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/index.js +2 -0
- package/lib/rules/class-to-functional.js +189 -181
- package/lib/rules/import-sorting.js +210 -199
- package/lib/rules/require-data-testid.js +221 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ module.exports = {
|
|
|
7
7
|
"use-state-naming": require("./lib/rules/use-state-naming"),
|
|
8
8
|
"class-to-functional": require("./lib/rules/class-to-functional"),
|
|
9
9
|
"import-sorting": require("./lib/rules/import-sorting"),
|
|
10
|
+
"require-data-testid": require("./lib/rules/require-data-testid"),
|
|
10
11
|
},
|
|
11
12
|
configs: {
|
|
12
13
|
recommended: {
|
|
@@ -19,6 +20,7 @@ module.exports = {
|
|
|
19
20
|
"power-esrules/use-state-naming": "error",
|
|
20
21
|
"power-esrules/class-to-functional": "error",
|
|
21
22
|
"power-esrules/import-sorting": "error",
|
|
23
|
+
"power-esrules/require-data-testid": "error",
|
|
22
24
|
},
|
|
23
25
|
},
|
|
24
26
|
},
|
|
@@ -7,230 +7,238 @@
|
|
|
7
7
|
* Условия для определения:
|
|
8
8
|
* 1. Не содержит внутреннего стейта (this.state)
|
|
9
9
|
* 2. Содержит от 1 до 4 методов класса (не считая методы жизненного цикла)
|
|
10
|
-
* Рекомендация: Запустите npm run codemod:classToFC -- <file-path>
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
|
-
const path = require(
|
|
12
|
+
const path = require("path");
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* Проверяет, является ли класс React компонентом
|
|
17
16
|
*/
|
|
18
17
|
function isReactComponentClass(node) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (superClass.type === 'Identifier') {
|
|
27
|
-
return superClass.name === 'Component' || superClass.name === 'PureComponent';
|
|
28
|
-
}
|
|
29
|
-
if (
|
|
30
|
-
superClass.type === 'MemberExpression' &&
|
|
31
|
-
superClass.object &&
|
|
32
|
-
superClass.object.type === 'Identifier' &&
|
|
33
|
-
superClass.object.name === 'React' &&
|
|
34
|
-
superClass.property &&
|
|
35
|
-
superClass.property.type === 'Identifier' &&
|
|
36
|
-
(superClass.property.name === 'Component' || superClass.property.name === 'PureComponent')
|
|
37
|
-
) {
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
18
|
+
if (
|
|
19
|
+
!node ||
|
|
20
|
+
(node.type !== "ClassDeclaration" && node.type !== "ClassExpression")
|
|
21
|
+
) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (!node.superClass) {
|
|
40
25
|
return false;
|
|
26
|
+
}
|
|
27
|
+
const { superClass } = node;
|
|
28
|
+
if (superClass.type === "Identifier") {
|
|
29
|
+
return (
|
|
30
|
+
superClass.name === "Component" || superClass.name === "PureComponent"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
superClass.type === "MemberExpression" &&
|
|
35
|
+
superClass.object &&
|
|
36
|
+
superClass.object.type === "Identifier" &&
|
|
37
|
+
superClass.object.name === "React" &&
|
|
38
|
+
superClass.property &&
|
|
39
|
+
superClass.property.type === "Identifier" &&
|
|
40
|
+
(superClass.property.name === "Component" ||
|
|
41
|
+
superClass.property.name === "PureComponent")
|
|
42
|
+
) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/**
|
|
44
49
|
* Проверяет, является ли метод методом жизненного цикла
|
|
45
50
|
*/
|
|
46
51
|
function isLifecycleMethod(methodName) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
const lifecycleMethods = [
|
|
53
|
+
"componentDidMount",
|
|
54
|
+
"componentDidUpdate",
|
|
55
|
+
"componentWillUnmount",
|
|
56
|
+
"componentWillMount",
|
|
57
|
+
"componentWillReceiveProps",
|
|
58
|
+
"UNSAFE_componentWillMount",
|
|
59
|
+
"UNSAFE_componentWillReceiveProps",
|
|
60
|
+
"UNSAFE_componentWillUpdate",
|
|
61
|
+
"shouldComponentUpdate",
|
|
62
|
+
"componentWillUpdate",
|
|
63
|
+
"getSnapshotBeforeUpdate",
|
|
64
|
+
"componentDidCatch",
|
|
65
|
+
"getDerivedStateFromProps",
|
|
66
|
+
];
|
|
67
|
+
return (
|
|
68
|
+
lifecycleMethods.includes(methodName) || methodName.startsWith("component")
|
|
69
|
+
);
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
73
|
* Проверяет, содержит ли узел использование this.state или this.setState
|
|
67
74
|
*/
|
|
68
75
|
function hasStateUsage(node) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
}
|
|
76
|
+
let hasState = false;
|
|
77
|
+
// Используем обход AST для поиска this.state и this.setState
|
|
78
|
+
function traverse(node) {
|
|
79
|
+
if (!node || hasState) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
node.type === "MemberExpression" &&
|
|
84
|
+
node.object &&
|
|
85
|
+
node.object.type === "ThisExpression" &&
|
|
86
|
+
node.property &&
|
|
87
|
+
node.property.type === "Identifier" &&
|
|
88
|
+
node.property.name === "state"
|
|
89
|
+
) {
|
|
90
|
+
hasState = true;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
node.type === "CallExpression" &&
|
|
95
|
+
node.callee &&
|
|
96
|
+
node.callee.type === "MemberExpression" &&
|
|
97
|
+
node.callee.object &&
|
|
98
|
+
node.callee.object.type === "ThisExpression" &&
|
|
99
|
+
node.callee.property &&
|
|
100
|
+
node.callee.property.type === "Identifier" &&
|
|
101
|
+
node.callee.property.name === "setState"
|
|
102
|
+
) {
|
|
103
|
+
hasState = true;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Рекурсивно обходим дочерние узлы
|
|
107
|
+
for (const key in node) {
|
|
108
|
+
if (key !== "parent" && node[key] && typeof node[key] === "object") {
|
|
109
|
+
if (Array.isArray(node[key])) {
|
|
110
|
+
node[key].forEach((child) => traverse(child));
|
|
111
|
+
} else {
|
|
112
|
+
traverse(node[key]);
|
|
108
113
|
}
|
|
114
|
+
}
|
|
109
115
|
}
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
}
|
|
117
|
+
traverse(node);
|
|
118
|
+
return hasState;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
/**
|
|
115
122
|
* Подсчитывает методы класса, исключая lifecycle методы, render и constructor
|
|
116
123
|
*/
|
|
117
124
|
function countNonLifecycleMethods(classBody) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
let count = 0;
|
|
126
|
+
if (!classBody || !classBody.body || !Array.isArray(classBody.body)) {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
classBody.body.forEach((member) => {
|
|
130
|
+
if (member.type === "MethodDefinition") {
|
|
131
|
+
const methodName = member.key && member.key.name;
|
|
132
|
+
if (methodName === "render" || methodName === "constructor") {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!isLifecycleMethod(methodName)) {
|
|
136
|
+
count++;
|
|
137
|
+
}
|
|
138
|
+
} else if (
|
|
139
|
+
member.type === "Property" ||
|
|
140
|
+
member.type === "ClassProperty" ||
|
|
141
|
+
member.type === "PropertyDefinition"
|
|
142
|
+
) {
|
|
143
|
+
const methodName = member.key && member.key.name;
|
|
144
|
+
if (methodName === "render") {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!isLifecycleMethod(methodName)) {
|
|
148
|
+
if (
|
|
149
|
+
member.value &&
|
|
150
|
+
(member.value.type === "ArrowFunctionExpression" ||
|
|
151
|
+
member.value.type === "FunctionExpression")
|
|
135
152
|
) {
|
|
136
|
-
|
|
137
|
-
if (methodName === 'render') {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (!isLifecycleMethod(methodName)) {
|
|
141
|
-
if (
|
|
142
|
-
member.value &&
|
|
143
|
-
(member.value.type === 'ArrowFunctionExpression' || member.value.type === 'FunctionExpression')
|
|
144
|
-
) {
|
|
145
|
-
count++;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
153
|
+
count++;
|
|
148
154
|
}
|
|
149
|
-
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
150
158
|
|
|
151
|
-
|
|
159
|
+
return count;
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
/**
|
|
155
163
|
* Получает относительный путь к файлу от корня проекта
|
|
156
164
|
*/
|
|
157
165
|
function getRelativeFilePath(context) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
const filename = context.getFilename();
|
|
167
|
+
const workspaceRoot = context.getCwd ? context.getCwd() : process.cwd();
|
|
168
|
+
return path.relative(workspaceRoot, filename);
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
module.exports = {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
},
|
|
172
|
+
meta: {
|
|
173
|
+
type: "suggestion",
|
|
174
|
+
docs: {
|
|
175
|
+
description:
|
|
176
|
+
"Определяет классовые компоненты для конвертации в функциональные и рекомендует запустить codemod",
|
|
177
|
+
category: "Best Practices",
|
|
178
|
+
recommended: false,
|
|
179
|
+
},
|
|
180
|
+
fixable: null,
|
|
181
|
+
schema: [],
|
|
182
|
+
messages: {
|
|
183
|
+
shouldConvertToFunctional:
|
|
184
|
+
'Классовый компонент "{{componentName}}" подходит для конвертации в функциональный. ' +
|
|
185
|
+
"Запустите:node node_modules/power-linter/scripts/classToFC/run-codemod.js {{filePath}}",
|
|
179
186
|
},
|
|
187
|
+
},
|
|
180
188
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
},
|
|
202
|
-
});
|
|
203
|
-
}
|
|
189
|
+
create(context) {
|
|
190
|
+
return {
|
|
191
|
+
ClassDeclaration(node) {
|
|
192
|
+
if (!isReactComponentClass(node)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const componentName = node.id ? node.id.name : "Unknown";
|
|
196
|
+
// Проверяем использование this.state
|
|
197
|
+
if (hasStateUsage(node)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const methodCount = countNonLifecycleMethods(node.body);
|
|
201
|
+
if (methodCount >= 1 && methodCount <= 4) {
|
|
202
|
+
const filePath = getRelativeFilePath(context);
|
|
203
|
+
context.report({
|
|
204
|
+
node: node.id || node,
|
|
205
|
+
messageId: "shouldConvertToFunctional",
|
|
206
|
+
data: {
|
|
207
|
+
componentName,
|
|
208
|
+
filePath,
|
|
204
209
|
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
},
|
|
205
213
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
}
|
|
214
|
+
ClassExpression(node) {
|
|
215
|
+
if (!isReactComponentClass(node)) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
let componentName = "Unknown";
|
|
219
|
+
const { parent } = node;
|
|
220
|
+
if (parent && parent.type === "VariableDeclarator" && parent.id) {
|
|
221
|
+
componentName = parent.id.name;
|
|
222
|
+
} else if (parent && parent.type === "ExportDefaultDeclaration") {
|
|
223
|
+
componentName = "DefaultExport";
|
|
224
|
+
}
|
|
225
|
+
// Проверяем использование this.state
|
|
226
|
+
if (hasStateUsage(node)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const methodCount = countNonLifecycleMethods(node.body);
|
|
230
|
+
if (methodCount >= 1 && methodCount <= 4) {
|
|
231
|
+
const filePath = getRelativeFilePath(context);
|
|
232
|
+
context.report({
|
|
233
|
+
node: node.parent && node.parent.id ? node.parent.id : node,
|
|
234
|
+
messageId: "shouldConvertToFunctional",
|
|
235
|
+
data: {
|
|
236
|
+
componentName,
|
|
237
|
+
filePath,
|
|
233
238
|
},
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
},
|
|
236
244
|
};
|
|
@@ -16,236 +16,247 @@
|
|
|
16
16
|
* @returns {string} - 'external', 'internal', или 'component'
|
|
17
17
|
*/
|
|
18
18
|
function getImportType(node) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
return 'internal';
|
|
19
|
+
const source = node.source.value;
|
|
20
|
+
// Относительные импорты (./ или ../) всегда внутренние
|
|
21
|
+
if (source.startsWith("./") || source.startsWith("../")) {
|
|
22
|
+
// Проверяем, является ли это компонентом
|
|
23
|
+
// Компоненты обычно импортируются как default или с большой буквы
|
|
24
|
+
const isDefaultImport = node.specifiers.some(
|
|
25
|
+
(spec) => spec.type === "ImportDefaultSpecifier"
|
|
26
|
+
);
|
|
27
|
+
const hasComponentName = node.specifiers.some(
|
|
28
|
+
(spec) =>
|
|
29
|
+
spec.type === "ImportSpecifier" &&
|
|
30
|
+
spec.imported &&
|
|
31
|
+
/^[A-Z]/.test(spec.imported.name)
|
|
32
|
+
);
|
|
33
|
+
if (isDefaultImport || hasComponentName) {
|
|
34
|
+
return "component";
|
|
37
35
|
}
|
|
36
|
+
return "internal";
|
|
37
|
+
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const hasComponentName = node.specifiers.some(
|
|
69
|
-
(spec) =>
|
|
70
|
-
spec.type === 'ImportSpecifier' &&
|
|
71
|
-
spec.imported &&
|
|
72
|
-
/^[A-Z][a-zA-Z]*$/.test(spec.imported.name) && // Имя начинается с большой буквы и не все заглавные
|
|
73
|
-
spec.imported.name !== spec.imported.name.toUpperCase() // Не константа (не все заглавные)
|
|
74
|
-
);
|
|
75
|
-
if (isDefaultImport || hasComponentName) {
|
|
76
|
-
return 'component';
|
|
77
|
-
}
|
|
39
|
+
// Сторонние библиотеки (не относительные и не начинаются с внутренних путей)
|
|
40
|
+
const internalPathPatterns = [
|
|
41
|
+
/^components\//,
|
|
42
|
+
/^utils\//,
|
|
43
|
+
/^modules\//,
|
|
44
|
+
/^pages\//,
|
|
45
|
+
/^services\//,
|
|
46
|
+
/^store\//,
|
|
47
|
+
/^types\//,
|
|
48
|
+
/^constants\//,
|
|
49
|
+
/^hooks\//,
|
|
50
|
+
/^helpers\//,
|
|
51
|
+
/^selectors\//,
|
|
52
|
+
/^base\//,
|
|
53
|
+
];
|
|
54
|
+
const isInternalPath = internalPathPatterns.some((pattern) =>
|
|
55
|
+
pattern.test(source)
|
|
56
|
+
);
|
|
57
|
+
if (!isInternalPath) {
|
|
58
|
+
return "external";
|
|
59
|
+
}
|
|
60
|
+
// Внутренние пути
|
|
61
|
+
// Компоненты обычно из папки components/ или имеют имена с большой буквы
|
|
62
|
+
if (source.startsWith("components/")) {
|
|
63
|
+
// Исключения: theme, constants, utils, helpers - это утилиты, не компоненты
|
|
64
|
+
const isUtility =
|
|
65
|
+
/components\/(theme|constants|utils|helpers|auxiliary)/.test(source);
|
|
66
|
+
if (isUtility) {
|
|
67
|
+
return "internal";
|
|
78
68
|
}
|
|
79
|
-
// Проверяем, является ли
|
|
69
|
+
// Проверяем, является ли это компонентом
|
|
70
|
+
const isDefaultImport = node.specifiers.some(
|
|
71
|
+
(spec) => spec.type === "ImportDefaultSpecifier"
|
|
72
|
+
);
|
|
80
73
|
const hasComponentName = node.specifiers.some(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
74
|
+
(spec) =>
|
|
75
|
+
spec.type === "ImportSpecifier" &&
|
|
76
|
+
spec.imported &&
|
|
77
|
+
/^[A-Z][a-zA-Z]*$/.test(spec.imported.name) && // Имя начинается с большой буквы и не все заглавные
|
|
78
|
+
spec.imported.name !== spec.imported.name.toUpperCase() // Не константа (не все заглавные)
|
|
86
79
|
);
|
|
87
|
-
if (hasComponentName) {
|
|
88
|
-
|
|
80
|
+
if (isDefaultImport || hasComponentName) {
|
|
81
|
+
return "component";
|
|
89
82
|
}
|
|
90
|
-
|
|
83
|
+
}
|
|
84
|
+
// Проверяем, является ли именованный импорт компонентом (начинается с большой буквы, но не константа)
|
|
85
|
+
const hasComponentName = node.specifiers.some(
|
|
86
|
+
(spec) =>
|
|
87
|
+
spec.type === "ImportSpecifier" &&
|
|
88
|
+
spec.imported &&
|
|
89
|
+
/^[A-Z][a-zA-Z]*$/.test(spec.imported.name) &&
|
|
90
|
+
spec.imported.name !== spec.imported.name.toUpperCase() // Не константа
|
|
91
|
+
);
|
|
92
|
+
if (hasComponentName) {
|
|
93
|
+
return "component";
|
|
94
|
+
}
|
|
95
|
+
return "internal";
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
/**
|
|
94
99
|
* Получает приоритет для сортировки
|
|
95
100
|
*/
|
|
96
101
|
function getImportPriority(type) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
const priorities = {
|
|
103
|
+
external: 1,
|
|
104
|
+
internal: 2,
|
|
105
|
+
component: 3,
|
|
106
|
+
};
|
|
107
|
+
return priorities[type] || 999;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/**
|
|
106
111
|
* Сравнивает два импорта для сортировки
|
|
107
112
|
*/
|
|
108
113
|
function compareImports(a, b) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
const typeA = getImportType(a);
|
|
115
|
+
const typeB = getImportType(b);
|
|
116
|
+
const priorityA = getImportPriority(typeA);
|
|
117
|
+
const priorityB = getImportPriority(typeB);
|
|
118
|
+
// Сначала по типу
|
|
119
|
+
if (priorityA !== priorityB) {
|
|
120
|
+
return priorityA - priorityB;
|
|
121
|
+
}
|
|
122
|
+
// Затем по имени источника (алфавитно)
|
|
123
|
+
const sourceA = a.source.value;
|
|
124
|
+
const sourceB = b.source.value;
|
|
125
|
+
return sourceA.localeCompare(sourceB);
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
/**
|
|
124
129
|
* Проверяет, нужна ли пустая строка между двумя импортами
|
|
125
130
|
*/
|
|
126
131
|
function needsBlankLineBetween(importA, importB) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
const typeA = getImportType(importA);
|
|
133
|
+
const typeB = getImportType(importB);
|
|
134
|
+
return typeA !== typeB;
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
module.exports = {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
},
|
|
140
|
-
fixable: 'code',
|
|
141
|
-
schema: [],
|
|
142
|
-
messages: {
|
|
143
|
-
// eslint-disable-next-line max-len
|
|
144
|
-
incorrectOrder: 'Импорты должны быть отсортированы: сначала сторонние библиотеки, затем внутренние утилиты, затем компоненты',
|
|
145
|
-
missingBlankLine: 'Требуется пустая строка между группами импортов',
|
|
146
|
-
},
|
|
138
|
+
meta: {
|
|
139
|
+
type: "layout",
|
|
140
|
+
docs: {
|
|
141
|
+
description: "Проверяет сортировку импортов согласно соглашению",
|
|
142
|
+
category: "Stylistic Issues",
|
|
143
|
+
recommended: false,
|
|
147
144
|
},
|
|
145
|
+
fixable: "code",
|
|
146
|
+
schema: [],
|
|
147
|
+
messages: {
|
|
148
|
+
// eslint-disable-next-line max-len
|
|
149
|
+
incorrectOrder:
|
|
150
|
+
"Импорты должны быть отсортированы: сначала сторонние библиотеки, затем внутренние утилиты, затем компоненты",
|
|
151
|
+
missingBlankLine: "Требуется пустая строка между группами импортов",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
148
154
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
// Проверяем порядок импортов
|
|
159
|
-
for (let i = 1; i < imports.length; i++) {
|
|
160
|
-
const prev = imports[i - 1];
|
|
161
|
-
const current = imports[i];
|
|
162
|
-
const comparison = compareImports(prev, current);
|
|
163
|
-
if (comparison > 0) {
|
|
164
|
-
context.report({
|
|
165
|
-
node: current,
|
|
166
|
-
messageId: 'incorrectOrder',
|
|
167
|
-
fix(fixer) {
|
|
168
|
-
// Находим все импорты и сортируем их
|
|
169
|
-
const sortedImports = [...imports].sort(compareImports);
|
|
170
|
-
const firstImport = imports[0];
|
|
171
|
-
const lastImport = imports[imports.length - 1];
|
|
172
|
-
const firstToken = sourceCode.getFirstToken(firstImport);
|
|
173
|
-
const lastToken = sourceCode.getLastToken(lastImport);
|
|
174
|
-
// Собираем текст для всех отсортированных импортов
|
|
175
|
-
let sortedText = '';
|
|
176
|
-
for (let j = 0; j < sortedImports.length; j++) {
|
|
177
|
-
const importNode = sortedImports[j];
|
|
178
|
-
// Получаем текст импорта и убираем завершающие переводы строк
|
|
179
|
-
let importText = sourceCode.getText(importNode);
|
|
180
|
-
// Убираем все завершающие переводы строк и пробелы
|
|
181
|
-
importText = importText.replace(/[\r\n\s]+$/, '');
|
|
182
|
-
sortedText += importText;
|
|
183
|
-
// Добавляем перевод строки между импортами
|
|
184
|
-
if (j < sortedImports.length - 1) {
|
|
185
|
-
const nextImport = sortedImports[j + 1];
|
|
186
|
-
if (needsBlankLineBetween(importNode, nextImport)) {
|
|
187
|
-
sortedText += '\n\n';
|
|
188
|
-
} else {
|
|
189
|
-
sortedText += '\n';
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return fixer.replaceTextRange([firstToken.range[0], lastToken.range[1]], sortedText);
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
return; // Исправляем только первую найденную ошибку за раз
|
|
197
|
-
}
|
|
198
|
-
}
|
|
155
|
+
create(context) {
|
|
156
|
+
const sourceCode = context.getSourceCode();
|
|
157
|
+
return {
|
|
158
|
+
Program(node) {
|
|
159
|
+
const imports = node.body.filter(
|
|
160
|
+
(stmt) => stmt.type === "ImportDeclaration"
|
|
161
|
+
);
|
|
199
162
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
163
|
+
if (imports.length === 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Проверяем порядок импортов
|
|
167
|
+
for (let i = 1; i < imports.length; i++) {
|
|
168
|
+
const prev = imports[i - 1];
|
|
169
|
+
const current = imports[i];
|
|
170
|
+
const comparison = compareImports(prev, current);
|
|
171
|
+
if (comparison > 0) {
|
|
172
|
+
context.report({
|
|
173
|
+
node: current,
|
|
174
|
+
messageId: "incorrectOrder",
|
|
175
|
+
fix(fixer) {
|
|
176
|
+
// Находим все импорты и сортируем их
|
|
177
|
+
const sortedImports = [...imports].sort(compareImports);
|
|
178
|
+
const firstImport = imports[0];
|
|
179
|
+
const lastImport = imports[imports.length - 1];
|
|
180
|
+
const firstToken = sourceCode.getFirstToken(firstImport);
|
|
181
|
+
const lastToken = sourceCode.getLastToken(lastImport);
|
|
182
|
+
// Собираем текст для всех отсортированных импортов
|
|
183
|
+
let sortedText = "";
|
|
184
|
+
for (let j = 0; j < sortedImports.length; j++) {
|
|
185
|
+
const importNode = sortedImports[j];
|
|
186
|
+
// Получаем текст импорта и убираем завершающие переводы строк
|
|
187
|
+
let importText = sourceCode.getText(importNode);
|
|
188
|
+
// Убираем все завершающие переводы строк и пробелы
|
|
189
|
+
importText = importText.replace(/[\r\n\s]+$/, "");
|
|
190
|
+
sortedText += importText;
|
|
191
|
+
// Добавляем перевод строки между импортами
|
|
192
|
+
if (j < sortedImports.length - 1) {
|
|
193
|
+
const nextImport = sortedImports[j + 1];
|
|
194
|
+
if (needsBlankLineBetween(importNode, nextImport)) {
|
|
195
|
+
sortedText += "\n\n";
|
|
219
196
|
} else {
|
|
220
|
-
|
|
221
|
-
const firstTokenCurrent = sourceCode.getFirstToken(current);
|
|
222
|
-
const lastTokenPrev = sourceCode.getLastToken(prev);
|
|
223
|
-
const lineDiff = firstTokenCurrent.loc.start.line - lastTokenPrev.loc.end.line;
|
|
224
|
-
const hasBlankLine = lineDiff > 1;
|
|
225
|
-
if (hasBlankLine) {
|
|
226
|
-
context.report({
|
|
227
|
-
node: current,
|
|
228
|
-
messageId: 'missingBlankLine',
|
|
229
|
-
fix(fixer) {
|
|
230
|
-
// Удаляем лишние пустые строки
|
|
231
|
-
const firstToken = sourceCode.getFirstToken(current);
|
|
232
|
-
const lastTokenOfPrev = sourceCode.getLastToken(prev);
|
|
233
|
-
const textBetween = sourceCode.getText().slice(
|
|
234
|
-
lastTokenOfPrev.range[1],
|
|
235
|
-
firstToken.range[0]
|
|
236
|
-
);
|
|
237
|
-
const fixedText = textBetween.replace(/\n\s*\n+/g, '\n');
|
|
238
|
-
return fixer.replaceTextRange(
|
|
239
|
-
[lastTokenOfPrev.range[1], firstToken.range[0]],
|
|
240
|
-
fixedText
|
|
241
|
-
);
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
}
|
|
197
|
+
sortedText += "\n";
|
|
245
198
|
}
|
|
199
|
+
}
|
|
246
200
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
201
|
+
return fixer.replaceTextRange(
|
|
202
|
+
[firstToken.range[0], lastToken.range[1]],
|
|
203
|
+
sortedText
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return; // Исправляем только первую найденную ошибку за раз
|
|
208
|
+
}
|
|
209
|
+
}
|
|
251
210
|
|
|
211
|
+
// Проверяем пустые строки между группами
|
|
212
|
+
for (let i = 1; i < imports.length; i++) {
|
|
213
|
+
const prev = imports[i - 1];
|
|
214
|
+
const current = imports[i];
|
|
215
|
+
if (needsBlankLineBetween(prev, current)) {
|
|
216
|
+
const firstTokenCurrent = sourceCode.getFirstToken(current);
|
|
217
|
+
const lastTokenPrev = sourceCode.getLastToken(prev);
|
|
218
|
+
const lineDiff =
|
|
219
|
+
firstTokenCurrent.loc.start.line - lastTokenPrev.loc.end.line;
|
|
220
|
+
const hasBlankLine = lineDiff > 1;
|
|
221
|
+
if (!hasBlankLine) {
|
|
222
|
+
context.report({
|
|
223
|
+
node: current,
|
|
224
|
+
messageId: "missingBlankLine",
|
|
225
|
+
fix(fixer) {
|
|
226
|
+
const firstToken = sourceCode.getFirstToken(current);
|
|
227
|
+
return fixer.insertTextBefore(firstToken, "\n");
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Проверяем, что нет лишних пустых строк внутри группы
|
|
233
|
+
const firstTokenCurrent = sourceCode.getFirstToken(current);
|
|
234
|
+
const lastTokenPrev = sourceCode.getLastToken(prev);
|
|
235
|
+
const lineDiff =
|
|
236
|
+
firstTokenCurrent.loc.start.line - lastTokenPrev.loc.end.line;
|
|
237
|
+
const hasBlankLine = lineDiff > 1;
|
|
238
|
+
if (hasBlankLine) {
|
|
239
|
+
context.report({
|
|
240
|
+
node: current,
|
|
241
|
+
messageId: "missingBlankLine",
|
|
242
|
+
fix(fixer) {
|
|
243
|
+
// Удаляем лишние пустые строки
|
|
244
|
+
const firstToken = sourceCode.getFirstToken(current);
|
|
245
|
+
const lastTokenOfPrev = sourceCode.getLastToken(prev);
|
|
246
|
+
const textBetween = sourceCode
|
|
247
|
+
.getText()
|
|
248
|
+
.slice(lastTokenOfPrev.range[1], firstToken.range[0]);
|
|
249
|
+
const fixedText = textBetween.replace(/\n\s*\n+/g, "\n");
|
|
250
|
+
return fixer.replaceTextRange(
|
|
251
|
+
[lastTokenOfPrev.range[1], firstToken.range[0]],
|
|
252
|
+
fixedText
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: require-data-testid
|
|
3
|
+
*
|
|
4
|
+
* Проверяет наличие data-testid атрибута у корневого контейнера JSX файла
|
|
5
|
+
* и рекомендует запустить codemod скрипт для его добавления, если атрибут отсутствует.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Получает имя JSX элемента
|
|
12
|
+
*/
|
|
13
|
+
function getJSXName(jsxName) {
|
|
14
|
+
if (!jsxName) return null;
|
|
15
|
+
switch (jsxName.type) {
|
|
16
|
+
case "JSXIdentifier":
|
|
17
|
+
return jsxName.name || null;
|
|
18
|
+
case "JSXMemberExpression":
|
|
19
|
+
// Берем правую часть: UI.Button -> Button
|
|
20
|
+
return getJSXName(jsxName.property);
|
|
21
|
+
case "JSXNamespacedName":
|
|
22
|
+
return `${jsxName.namespace?.name || "ns"}:${
|
|
23
|
+
jsxName.name?.name || "Name"
|
|
24
|
+
}`;
|
|
25
|
+
default:
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Проверяет, является ли узел верхнеуровневым JSX элементом
|
|
32
|
+
* (не вложенным в другой JSX элемент или Fragment)
|
|
33
|
+
*/
|
|
34
|
+
function isTopLevelJSX(node) {
|
|
35
|
+
let current = node.parent;
|
|
36
|
+
while (current) {
|
|
37
|
+
const parentType = current.type;
|
|
38
|
+
// Если родитель - JSX элемент или Fragment, значит это не верхнеуровневый элемент
|
|
39
|
+
if (parentType === "JSXElement" || parentType === "JSXFragment") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Если родитель - ReturnStatement, это возвращаемое значение компонента (верхнеуровневый)
|
|
43
|
+
if (parentType === "ReturnStatement") {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// Если родитель - ExportDefaultDeclaration или ExportNamedDeclaration,
|
|
47
|
+
// это может быть верхнеуровневый JSX
|
|
48
|
+
if (
|
|
49
|
+
parentType === "ExportDefaultDeclaration" ||
|
|
50
|
+
parentType === "ExportNamedDeclaration"
|
|
51
|
+
) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
current = current.parent;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Спускается от Fragment к первому рендеримому JSXElement
|
|
61
|
+
*/
|
|
62
|
+
function descendToFirstRenderable(node) {
|
|
63
|
+
if (!node) return null;
|
|
64
|
+
|
|
65
|
+
// 1) Если это JSXFragment — обходим детей
|
|
66
|
+
if (node.type === "JSXFragment") {
|
|
67
|
+
for (const child of node.children || []) {
|
|
68
|
+
if (child.type === "JSXElement") {
|
|
69
|
+
const resolved = descendToFirstRenderable(child);
|
|
70
|
+
if (resolved) return resolved;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2) Если это JSXElement с именем Fragment
|
|
77
|
+
if (node.type === "JSXElement") {
|
|
78
|
+
const name = getJSXName(node.openingElement?.name);
|
|
79
|
+
if (name === "Fragment") {
|
|
80
|
+
for (const child of node.children || []) {
|
|
81
|
+
if (child.type === "JSXElement") {
|
|
82
|
+
const resolved = descendToFirstRenderable(child);
|
|
83
|
+
if (resolved) return resolved;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Иначе это реальный рендеримый элемент
|
|
89
|
+
return node;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Проверяет наличие data-testid или dataTestID атрибута
|
|
97
|
+
*/
|
|
98
|
+
function hasDataTestId(openingElement) {
|
|
99
|
+
if (!openingElement || !openingElement.attributes) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return openingElement.attributes.some((attr) => {
|
|
103
|
+
if (!attr || attr.type !== "JSXAttribute" || !attr.name) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const attrName = attr.name.name;
|
|
107
|
+
return attrName === "data-testid" || attrName === "dataTestID";
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Получает относительный путь к файлу от корня проекта
|
|
113
|
+
*/
|
|
114
|
+
function getRelativeFilePath(context) {
|
|
115
|
+
const filename = context.getFilename();
|
|
116
|
+
const workspaceRoot = context.getCwd ? context.getCwd() : process.cwd();
|
|
117
|
+
return path.relative(workspaceRoot, filename);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
meta: {
|
|
122
|
+
type: "suggestion",
|
|
123
|
+
docs: {
|
|
124
|
+
description:
|
|
125
|
+
"Проверяет наличие data-testid атрибута у корневого контейнера JSX файла и рекомендует запустить codemod",
|
|
126
|
+
category: "Best Practices",
|
|
127
|
+
recommended: false,
|
|
128
|
+
},
|
|
129
|
+
fixable: null,
|
|
130
|
+
schema: [],
|
|
131
|
+
messages: {
|
|
132
|
+
missingDataTestId:
|
|
133
|
+
"Корневой контейнер JSX не содержит атрибут data-testid. " +
|
|
134
|
+
"Запустите: node node_modules/power-linter/scripts/addDataTestId/run-codemod.js {{filePath}}" +
|
|
135
|
+
"или node node_modules/power-linter/scripts/addDataTestId/run-codemod.js src для всего проекта",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
create(context) {
|
|
140
|
+
let hasReported = false;
|
|
141
|
+
let rootJSXNode = null;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ReturnStatement(node) {
|
|
145
|
+
// Проверяем только один раз на файл
|
|
146
|
+
if (hasReported || rootJSXNode) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const returnArgument = node.argument;
|
|
151
|
+
if (!returnArgument) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Если возвращается JSX элемент или Fragment
|
|
156
|
+
if (
|
|
157
|
+
returnArgument.type === "JSXElement" ||
|
|
158
|
+
returnArgument.type === "JSXFragment"
|
|
159
|
+
) {
|
|
160
|
+
// Проверяем, что это не вложенный JSX (не имеет JSX родителя)
|
|
161
|
+
if (isTopLevelJSX(returnArgument)) {
|
|
162
|
+
rootJSXNode = returnArgument;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
JSXElement(node) {
|
|
168
|
+
// Проверяем только один раз на файл
|
|
169
|
+
if (hasReported || rootJSXNode) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Проверяем верхнеуровневые JSX элементы вне ReturnStatement
|
|
174
|
+
// (например, экспортируемые напрямую)
|
|
175
|
+
if (isTopLevelJSX(node)) {
|
|
176
|
+
rootJSXNode = node;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
JSXFragment(node) {
|
|
181
|
+
// Проверяем только один раз на файл
|
|
182
|
+
if (hasReported || rootJSXNode) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Проверяем верхнеуровневые Fragment вне ReturnStatement
|
|
187
|
+
if (isTopLevelJSX(node)) {
|
|
188
|
+
rootJSXNode = node;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
"Program:exit"() {
|
|
193
|
+
// После обхода всего файла проверяем корневой JSX элемент
|
|
194
|
+
if (hasReported || !rootJSXNode) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Если это Fragment, спускаемся к первому рендеримому элементу
|
|
199
|
+
const rootElement = descendToFirstRenderable(rootJSXNode);
|
|
200
|
+
const elementToCheck = rootElement || rootJSXNode;
|
|
201
|
+
|
|
202
|
+
if (!elementToCheck.openingElement) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Проверяем наличие data-testid
|
|
207
|
+
if (!hasDataTestId(elementToCheck.openingElement)) {
|
|
208
|
+
hasReported = true;
|
|
209
|
+
const filePath = getRelativeFilePath(context);
|
|
210
|
+
context.report({
|
|
211
|
+
node: elementToCheck.openingElement,
|
|
212
|
+
messageId: "missingDataTestId",
|
|
213
|
+
data: {
|
|
214
|
+
filePath,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|