eslint-plugin-power-esrules 0.1.11 → 0.1.13

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.
@@ -12,6 +12,7 @@
12
12
  - `use-state-naming.js` - проверка именования useState хуков
13
13
  - `class-to-functional.js` - определение классовых компонентов для конвертации в функциональные
14
14
  - `import-sorting.js` - сортировка импортов
15
+ - `props-destructuring-sort.js` - сортировка деструктурированных props
15
16
 
16
17
  ### Правила плагина
17
18
 
@@ -66,6 +67,16 @@
66
67
  #### 7. `import-sorting`
67
68
  **Назначение:** Сортирует импорты согласно правилам установленным в команде разработки.
68
69
 
70
+ #### 8. `props-destructuring-sort`
71
+ **Назначение:** Сортирует деструктурированные props React-компонентов по алфавиту.
72
+
73
+ **Логика:**
74
+ - `const { ... } = props` — только внутри функционального React-компонента
75
+ - `const { ... } = this.props` — только внутри классового React-компонента (`Component` / `PureComponent`)
76
+ - Первый параметр `({ ... })` — только у React-компонента (имя с заглавной буквы, JSX в теле, или обёртка `memo` / `forwardRef` / `lazy`)
77
+ - Rest-элемент (`...rest`) остаётся последним
78
+ - Автоисправление при сохранении (eslint --fix), как у `import-sorting`
79
+
69
80
  ## Что нужно для интеграции
70
81
 
71
82
  ### 1. npm install eslint-plugin-power-esrules
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
+ "props-destructuring-sort": require("./lib/rules/props-destructuring-sort"),
10
11
  "require-data-testid": require("./lib/rules/require-data-testid"),
11
12
  },
12
13
  configs: {
@@ -20,6 +21,7 @@ module.exports = {
20
21
  "power-esrules/use-state-naming": "warn",
21
22
  "power-esrules/class-to-functional": "error",
22
23
  "power-esrules/import-sorting": "error",
24
+ "power-esrules/props-destructuring-sort": "error",
23
25
  "power-esrules/require-data-testid": "warn",
24
26
  },
25
27
  },
@@ -91,7 +91,8 @@ function getImportType(node) {
91
91
  spec.type === "ImportSpecifier" &&
92
92
  spec.imported &&
93
93
  /^[A-Z][a-zA-Z]*$/.test(spec.imported.name) && // Имя начинается с большой буквы и не все заглавные
94
- spec.imported.name !== spec.imported.name.toUpperCase(), // Не константа (не все заглавные)
94
+ spec.imported.name !== spec.imported.name.toUpperCase() && // Не константа (не все заглавные)
95
+ !spec.imported.name.endsWith("Context"), // не содержит ключевого слова Context
95
96
  );
96
97
  if (isDefaultImport || hasComponentName) {
97
98
  return "component";
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Rule: props-destructuring-sort
3
+ *
4
+ * Сортирует деструктурированные props React-компонентов по алфавиту:
5
+ * - const { ... } = props (внутри функционального React-компонента)
6
+ * - const { ... } = this.props (внутри классового React-компонента)
7
+ * - function Component({ ... }) и аналогичные стрелочные/function expression
8
+ */
9
+
10
+ const REACT_WRAPPER_CALLEES = new Set(["memo", "forwardRef", "lazy"]);
11
+
12
+ const NESTED_FUNCTION_TYPES = new Set([
13
+ "FunctionDeclaration",
14
+ "FunctionExpression",
15
+ "ArrowFunctionExpression",
16
+ ]);
17
+
18
+ function isPropsInitializer(node) {
19
+ if (!node) {
20
+ return false;
21
+ }
22
+ if (node.type === "Identifier" && node.name === "props") {
23
+ return true;
24
+ }
25
+ if (
26
+ node.type === "MemberExpression" &&
27
+ !node.computed &&
28
+ node.object.type === "ThisExpression" &&
29
+ node.property.type === "Identifier" &&
30
+ node.property.name === "props"
31
+ ) {
32
+ return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function isReactComponentName(name) {
38
+ if (!name || typeof name !== "string") {
39
+ return false;
40
+ }
41
+ return /^[A-Z]/.test(name);
42
+ }
43
+
44
+ function isReactComponentClass(node) {
45
+ if (
46
+ !node ||
47
+ (node.type !== "ClassDeclaration" && node.type !== "ClassExpression")
48
+ ) {
49
+ return false;
50
+ }
51
+ if (!node.superClass) {
52
+ return false;
53
+ }
54
+ const { superClass } = node;
55
+ if (superClass.type === "Identifier") {
56
+ return (
57
+ superClass.name === "Component" || superClass.name === "PureComponent"
58
+ );
59
+ }
60
+ if (
61
+ superClass.type === "MemberExpression" &&
62
+ superClass.object &&
63
+ superClass.object.type === "Identifier" &&
64
+ superClass.object.name === "React" &&
65
+ superClass.property &&
66
+ superClass.property.type === "Identifier" &&
67
+ (superClass.property.name === "Component" ||
68
+ superClass.property.name === "PureComponent")
69
+ ) {
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ function getCalleeName(callee) {
76
+ if (!callee) {
77
+ return null;
78
+ }
79
+ if (callee.type === "Identifier") {
80
+ return callee.name;
81
+ }
82
+ if (
83
+ callee.type === "MemberExpression" &&
84
+ !callee.computed &&
85
+ callee.property.type === "Identifier"
86
+ ) {
87
+ return callee.property.name;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function isWrappedByReactWrapper(funcNode) {
93
+ let current = funcNode.parent;
94
+ while (current) {
95
+ if (current.type === "CallExpression") {
96
+ const calleeName = getCalleeName(current.callee);
97
+ if (calleeName && REACT_WRAPPER_CALLEES.has(calleeName)) {
98
+ return current.arguments[0] === funcNode;
99
+ }
100
+ }
101
+ current = current.parent;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function getReactComponentName(funcNode) {
107
+ if (funcNode.type === "FunctionDeclaration" && funcNode.id) {
108
+ return funcNode.id.name;
109
+ }
110
+ const { parent } = funcNode;
111
+ if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
112
+ return parent.id.name;
113
+ }
114
+ if (parent?.type === "AssignmentExpression" && parent.left?.type === "Identifier") {
115
+ return parent.left.name;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function nodeContainsJSX(node) {
121
+ if (!node || typeof node !== "object") {
122
+ return false;
123
+ }
124
+ if (node.type === "JSXElement" || node.type === "JSXFragment") {
125
+ return true;
126
+ }
127
+ for (const key of Object.keys(node)) {
128
+ if (key === "parent") {
129
+ continue;
130
+ }
131
+ const value = node[key];
132
+ if (!value) {
133
+ continue;
134
+ }
135
+ if (Array.isArray(value)) {
136
+ for (const item of value) {
137
+ if (!item || typeof item !== "object" || !item.type) {
138
+ continue;
139
+ }
140
+ if (NESTED_FUNCTION_TYPES.has(item.type)) {
141
+ continue;
142
+ }
143
+ if (nodeContainsJSX(item)) {
144
+ return true;
145
+ }
146
+ }
147
+ } else if (typeof value === "object" && value.type) {
148
+ if (NESTED_FUNCTION_TYPES.has(value.type)) {
149
+ continue;
150
+ }
151
+ if (nodeContainsJSX(value)) {
152
+ return true;
153
+ }
154
+ }
155
+ }
156
+ return false;
157
+ }
158
+
159
+ function functionBodyContainsJSX(funcNode) {
160
+ const { body } = funcNode;
161
+ if (!body) {
162
+ return false;
163
+ }
164
+ if (body.type === "JSXElement" || body.type === "JSXFragment") {
165
+ return true;
166
+ }
167
+ return nodeContainsJSX(body);
168
+ }
169
+
170
+ function isReactComponentFunction(funcNode) {
171
+ if (!funcNode || !NESTED_FUNCTION_TYPES.has(funcNode.type)) {
172
+ return false;
173
+ }
174
+ if (isWrappedByReactWrapper(funcNode)) {
175
+ return true;
176
+ }
177
+ const componentName = getReactComponentName(funcNode);
178
+ if (isReactComponentName(componentName)) {
179
+ return true;
180
+ }
181
+ return functionBodyContainsJSX(funcNode);
182
+ }
183
+
184
+ function getEnclosingFunction(node) {
185
+ let current = node.parent;
186
+ while (current) {
187
+ if (NESTED_FUNCTION_TYPES.has(current.type)) {
188
+ return current;
189
+ }
190
+ current = current.parent;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function isInsideReactClass(node) {
196
+ let current = node.parent;
197
+ while (current) {
198
+ if (current.type === "ClassDeclaration" || current.type === "ClassExpression") {
199
+ return isReactComponentClass(current);
200
+ }
201
+ current = current.parent;
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function isInsideReactComponentFunction(node) {
207
+ const enclosingFunction = getEnclosingFunction(node);
208
+ if (!enclosingFunction) {
209
+ return false;
210
+ }
211
+ return isReactComponentFunction(enclosingFunction);
212
+ }
213
+
214
+ function getPropertySortKey(prop) {
215
+ if (prop.type === "RestElement") {
216
+ return null;
217
+ }
218
+ if (prop.type === "Property") {
219
+ if (prop.key.type === "Identifier" && !prop.computed) {
220
+ return prop.key.name;
221
+ }
222
+ if (prop.key.type === "Literal") {
223
+ return String(prop.key.value);
224
+ }
225
+ }
226
+ return "";
227
+ }
228
+
229
+ function partitionProperties(properties) {
230
+ const regular = [];
231
+ const rest = [];
232
+ for (const prop of properties) {
233
+ if (prop.type === "RestElement") {
234
+ rest.push(prop);
235
+ } else {
236
+ regular.push(prop);
237
+ }
238
+ }
239
+ return { regular, rest };
240
+ }
241
+
242
+ function isAlphabeticallySorted(regular) {
243
+ for (let i = 1; i < regular.length; i += 1) {
244
+ const prevKey = getPropertySortKey(regular[i - 1]);
245
+ const currentKey = getPropertySortKey(regular[i]);
246
+ if (prevKey.localeCompare(currentKey) > 0) {
247
+ return false;
248
+ }
249
+ }
250
+ return true;
251
+ }
252
+
253
+ function sortObjectPatternProperties(properties) {
254
+ const { regular, rest } = partitionProperties(properties);
255
+ const sortedRegular = [...regular].sort((a, b) =>
256
+ getPropertySortKey(a).localeCompare(getPropertySortKey(b)),
257
+ );
258
+ return [...sortedRegular, ...rest];
259
+ }
260
+
261
+ function getPropertiesSeparator(sourceCode, properties) {
262
+ if (properties.length < 2) {
263
+ return ", ";
264
+ }
265
+ const comma = sourceCode.getTokenAfter(properties[0], {
266
+ filter: (token) => token.value === ",",
267
+ });
268
+ if (!comma) {
269
+ return ", ";
270
+ }
271
+ return sourceCode.text.slice(comma.range[0], properties[1].range[0]);
272
+ }
273
+
274
+ function buildSortedObjectPatternText(patternNode, sortedProperties, sourceCode) {
275
+ const openBrace = sourceCode.getFirstToken(patternNode);
276
+ const closeBrace = sourceCode.getLastToken(patternNode);
277
+ const originalProperties = patternNode.properties;
278
+ const separator = getPropertiesSeparator(sourceCode, originalProperties);
279
+
280
+ let innerText = "";
281
+ for (let i = 0; i < sortedProperties.length; i += 1) {
282
+ innerText += sourceCode.getText(sortedProperties[i]);
283
+ if (i < sortedProperties.length - 1) {
284
+ innerText += separator;
285
+ }
286
+ }
287
+
288
+ const afterOpenBrace = sourceCode.text.slice(
289
+ openBrace.range[1],
290
+ originalProperties[0].range[0],
291
+ );
292
+ const lastOriginal = originalProperties[originalProperties.length - 1];
293
+ const beforeCloseBrace = sourceCode.text.slice(
294
+ lastOriginal.range[1],
295
+ closeBrace.range[0],
296
+ );
297
+
298
+ return `{${afterOpenBrace}${innerText}${beforeCloseBrace}}`;
299
+ }
300
+
301
+ function checkObjectPattern(patternNode, context) {
302
+ const { properties } = patternNode;
303
+ if (!properties || properties.length < 2) {
304
+ return;
305
+ }
306
+
307
+ const { regular } = partitionProperties(properties);
308
+ if (regular.length < 2 || isAlphabeticallySorted(regular)) {
309
+ return;
310
+ }
311
+
312
+ const sourceCode = context.getSourceCode();
313
+ context.report({
314
+ node: patternNode,
315
+ messageId: "incorrectOrder",
316
+ fix(fixer) {
317
+ const sortedProperties = sortObjectPatternProperties(properties);
318
+ const sortedText = buildSortedObjectPatternText(
319
+ patternNode,
320
+ sortedProperties,
321
+ sourceCode,
322
+ );
323
+ return fixer.replaceText(patternNode, sortedText);
324
+ },
325
+ });
326
+ }
327
+
328
+ function checkFunctionParams(funcNode, context) {
329
+ if (!isReactComponentFunction(funcNode)) {
330
+ return;
331
+ }
332
+ const { params } = funcNode;
333
+ if (!params || params.length === 0) {
334
+ return;
335
+ }
336
+ const firstParam = params[0];
337
+ if (firstParam.type === "ObjectPattern") {
338
+ checkObjectPattern(firstParam, context);
339
+ }
340
+ }
341
+
342
+ module.exports = {
343
+ meta: {
344
+ type: "layout",
345
+ docs: {
346
+ description:
347
+ "Сортирует деструктурированные props React-компонентов по алфавиту",
348
+ category: "Stylistic Issues",
349
+ recommended: false,
350
+ },
351
+ fixable: "code",
352
+ schema: [],
353
+ messages: {
354
+ incorrectOrder:
355
+ "Деструктурированные props должны быть отсортированы по алфавиту",
356
+ },
357
+ },
358
+
359
+ create(context) {
360
+ return {
361
+ VariableDeclarator(node) {
362
+ if (node.id.type !== "ObjectPattern" || !isPropsInitializer(node.init)) {
363
+ return;
364
+ }
365
+ const isThisProps =
366
+ node.init.type === "MemberExpression" &&
367
+ node.init.object.type === "ThisExpression";
368
+ if (isThisProps) {
369
+ if (!isInsideReactClass(node)) {
370
+ return;
371
+ }
372
+ } else if (!isInsideReactComponentFunction(node)) {
373
+ return;
374
+ }
375
+ checkObjectPattern(node.id, context);
376
+ },
377
+
378
+ FunctionDeclaration(node) {
379
+ checkFunctionParams(node, context);
380
+ },
381
+
382
+ FunctionExpression(node) {
383
+ checkFunctionParams(node, context);
384
+ },
385
+
386
+ ArrowFunctionExpression(node) {
387
+ checkFunctionParams(node, context);
388
+ },
389
+ };
390
+ },
391
+ };
@@ -0,0 +1,223 @@
1
+ // /**
2
+ // * Rule: require-data-testid
3
+ // *
4
+ // * Проверяет наличие data-testid атрибута у корневого контейнера JSX файла
5
+ // * и рекомендует запустить codemod скрипт для его добавления, если атрибут отсутствует.
6
+ // */
7
+
8
+ // const path = require("path");
9
+
10
+ // const packetName = "@hubex/power-linter";
11
+
12
+ // /**
13
+ // * Получает имя JSX элемента
14
+ // */
15
+ // function getJSXName(jsxName) {
16
+ // if (!jsxName) return null;
17
+ // switch (jsxName.type) {
18
+ // case "JSXIdentifier":
19
+ // return jsxName.name || null;
20
+ // case "JSXMemberExpression":
21
+ // // Берем правую часть: UI.Button -> Button
22
+ // return getJSXName(jsxName.property);
23
+ // case "JSXNamespacedName":
24
+ // return `${jsxName.namespace?.name || "ns"}:${
25
+ // jsxName.name?.name || "Name"
26
+ // }`;
27
+ // default:
28
+ // return null;
29
+ // }
30
+ // }
31
+
32
+ // /**
33
+ // * Проверяет, является ли узел верхнеуровневым JSX элементом
34
+ // * (не вложенным в другой JSX элемент или Fragment)
35
+ // */
36
+ // function isTopLevelJSX(node) {
37
+ // let current = node.parent;
38
+ // while (current) {
39
+ // const parentType = current.type;
40
+ // // Если родитель - JSX элемент или Fragment, значит это не верхнеуровневый элемент
41
+ // if (parentType === "JSXElement" || parentType === "JSXFragment") {
42
+ // return false;
43
+ // }
44
+ // // Если родитель - ReturnStatement, это возвращаемое значение компонента (верхнеуровневый)
45
+ // if (parentType === "ReturnStatement") {
46
+ // return true;
47
+ // }
48
+ // // Если родитель - ExportDefaultDeclaration или ExportNamedDeclaration,
49
+ // // это может быть верхнеуровневый JSX
50
+ // if (
51
+ // parentType === "ExportDefaultDeclaration" ||
52
+ // parentType === "ExportNamedDeclaration"
53
+ // ) {
54
+ // return true;
55
+ // }
56
+ // current = current.parent;
57
+ // }
58
+ // return false;
59
+ // }
60
+
61
+ // /**
62
+ // * Спускается от Fragment к первому рендеримому JSXElement
63
+ // */
64
+ // function descendToFirstRenderable(node) {
65
+ // if (!node) return null;
66
+
67
+ // // 1) Если это JSXFragment — обходим детей
68
+ // if (node.type === "JSXFragment") {
69
+ // for (const child of node.children || []) {
70
+ // if (child.type === "JSXElement") {
71
+ // const resolved = descendToFirstRenderable(child);
72
+ // if (resolved) return resolved;
73
+ // }
74
+ // }
75
+ // return null;
76
+ // }
77
+
78
+ // // 2) Если это JSXElement с именем Fragment
79
+ // if (node.type === "JSXElement") {
80
+ // const name = getJSXName(node.openingElement?.name);
81
+ // if (name === "Fragment") {
82
+ // for (const child of node.children || []) {
83
+ // if (child.type === "JSXElement") {
84
+ // const resolved = descendToFirstRenderable(child);
85
+ // if (resolved) return resolved;
86
+ // }
87
+ // }
88
+ // return null;
89
+ // }
90
+ // // Иначе это реальный рендеримый элемент
91
+ // return node;
92
+ // }
93
+
94
+ // return null;
95
+ // }
96
+
97
+ // /**
98
+ // * Проверяет наличие data-testid или dataTestID атрибута
99
+ // */
100
+ // function hasDataTestId(openingElement) {
101
+ // if (!openingElement || !openingElement.attributes) {
102
+ // return false;
103
+ // }
104
+ // return openingElement.attributes.some((attr) => {
105
+ // if (!attr || attr.type !== "JSXAttribute" || !attr.name) {
106
+ // return false;
107
+ // }
108
+ // const attrName = attr.name.name;
109
+ // return attrName === "data-testid" || attrName === "dataTestID";
110
+ // });
111
+ // }
112
+
113
+ // /**
114
+ // * Получает относительный путь к файлу от корня проекта
115
+ // */
116
+ // function getRelativeFilePath(context) {
117
+ // const filename = context.getFilename();
118
+ // const workspaceRoot = context.getCwd ? context.getCwd() : process.cwd();
119
+ // return path.relative(workspaceRoot, filename);
120
+ // }
121
+
122
+ // module.exports = {
123
+ // meta: {
124
+ // type: "suggestion",
125
+ // docs: {
126
+ // description:
127
+ // "Проверяет наличие data-testid атрибута у корневого контейнера JSX файла и рекомендует запустить codemod",
128
+ // category: "Best Practices",
129
+ // recommended: false,
130
+ // },
131
+ // fixable: null,
132
+ // schema: [],
133
+ // messages: {
134
+ // missingDataTestId:
135
+ // "Корневой контейнер JSX не содержит атрибут data-testid. " +
136
+ // `Запустите: node node_modules/${packetName}/scripts/addDataTestId/run-codemod.js {{filePath}}` +
137
+ // ` или node node_modules/${packetName}/scripts/addDataTestId/run-codemod.js src для всего проекта`,
138
+ // },
139
+ // },
140
+
141
+ // create(context) {
142
+ // let hasReported = false;
143
+ // let rootJSXNode = null;
144
+
145
+ // return {
146
+ // ReturnStatement(node) {
147
+ // // Проверяем только один раз на файл
148
+ // if (hasReported || rootJSXNode) {
149
+ // return;
150
+ // }
151
+
152
+ // const returnArgument = node.argument;
153
+ // if (!returnArgument) {
154
+ // return;
155
+ // }
156
+
157
+ // // Если возвращается JSX элемент или Fragment
158
+ // if (
159
+ // returnArgument.type === "JSXElement" ||
160
+ // returnArgument.type === "JSXFragment"
161
+ // ) {
162
+ // // Проверяем, что это не вложенный JSX (не имеет JSX родителя)
163
+ // if (isTopLevelJSX(returnArgument)) {
164
+ // rootJSXNode = returnArgument;
165
+ // }
166
+ // }
167
+ // },
168
+
169
+ // JSXElement(node) {
170
+ // // Проверяем только один раз на файл
171
+ // if (hasReported || rootJSXNode) {
172
+ // return;
173
+ // }
174
+
175
+ // // Проверяем верхнеуровневые JSX элементы вне ReturnStatement
176
+ // // (например, экспортируемые напрямую)
177
+ // if (isTopLevelJSX(node)) {
178
+ // rootJSXNode = node;
179
+ // }
180
+ // },
181
+
182
+ // JSXFragment(node) {
183
+ // // Проверяем только один раз на файл
184
+ // if (hasReported || rootJSXNode) {
185
+ // return;
186
+ // }
187
+
188
+ // // Проверяем верхнеуровневые Fragment вне ReturnStatement
189
+ // if (isTopLevelJSX(node)) {
190
+ // rootJSXNode = node;
191
+ // }
192
+ // },
193
+
194
+ // "Program:exit"() {
195
+ // // После обхода всего файла проверяем корневой JSX элемент
196
+ // if (hasReported || !rootJSXNode) {
197
+ // return;
198
+ // }
199
+
200
+ // // Если это Fragment, спускаемся к первому рендеримому элементу
201
+ // const rootElement = descendToFirstRenderable(rootJSXNode);
202
+ // const elementToCheck = rootElement || rootJSXNode;
203
+
204
+ // if (!elementToCheck.openingElement) {
205
+ // return;
206
+ // }
207
+
208
+ // // Проверяем наличие data-testid
209
+ // if (!hasDataTestId(elementToCheck.openingElement)) {
210
+ // hasReported = true;
211
+ // const filePath = getRelativeFilePath(context);
212
+ // context.report({
213
+ // node: elementToCheck.openingElement,
214
+ // messageId: "missingDataTestId",
215
+ // data: {
216
+ // filePath,
217
+ // },
218
+ // });
219
+ // }
220
+ // },
221
+ // };
222
+ // },
223
+ // };
@@ -1,24 +1,20 @@
1
1
  /**
2
2
  * Rule: require-data-testid
3
3
  *
4
- * Проверяет наличие data-testid атрибута у корневого контейнера JSX файла
4
+ * Проверяет наличие data-testid атрибута у корневого JSX элемента React-компонента
5
5
  * и рекомендует запустить codemod скрипт для его добавления, если атрибут отсутствует.
6
6
  */
7
7
 
8
8
  const path = require("path");
9
-
10
9
  const packetName = "@hubex/power-linter";
11
10
 
12
- /**
13
- * Получает имя JSX элемента
14
- */
11
+ /** Получает имя JSX элемента */
15
12
  function getJSXName(jsxName) {
16
13
  if (!jsxName) return null;
17
14
  switch (jsxName.type) {
18
15
  case "JSXIdentifier":
19
16
  return jsxName.name || null;
20
17
  case "JSXMemberExpression":
21
- // Берем правую часть: UI.Button -> Button
22
18
  return getJSXName(jsxName.property);
23
19
  case "JSXNamespacedName":
24
20
  return `${jsxName.namespace?.name || "ns"}:${
@@ -29,42 +25,81 @@ function getJSXName(jsxName) {
29
25
  }
30
26
  }
31
27
 
32
- /**
33
- * Проверяет, является ли узел верхнеуровневым JSX элементом
34
- * (не вложенным в другой JSX элемент или Fragment)
35
- */
36
- function isTopLevelJSX(node) {
37
- let current = node.parent;
38
- while (current) {
39
- const parentType = current.type;
40
- // Если родитель - JSX элемент или Fragment, значит это не верхнеуровневый элемент
41
- if (parentType === "JSXElement" || parentType === "JSXFragment") {
42
- return false;
43
- }
44
- // Если родитель - ReturnStatement, это возвращаемое значение компонента (верхнеуровневый)
45
- if (parentType === "ReturnStatement") {
46
- return true;
47
- }
48
- // Если родитель - ExportDefaultDeclaration или ExportNamedDeclaration,
49
- // это может быть верхнеуровневый JSX
28
+ /** Проверка PascalCase */
29
+ function isPascalCase(name) {
30
+ return /^[A-Z]/.test(name);
31
+ }
32
+
33
+ /** Находит имя компонента, учитывая HOC (memo, forwardRef, observer) */
34
+ function getComponentName(node) {
35
+ if (!node) return null;
36
+
37
+ // Функция объявлена напрямую
38
+ if (node.type === "FunctionDeclaration") {
39
+ return node.id?.name ?? null;
40
+ }
41
+
42
+ // Переменная с функцией
43
+ if (
44
+ (node.type === "ArrowFunctionExpression" ||
45
+ node.type === "FunctionExpression") &&
46
+ node.parent?.type === "VariableDeclarator"
47
+ ) {
48
+ return node.parent.id?.name ?? null;
49
+ }
50
+
51
+ // HOC обёртка: memo(() => {}) или forwardRef(() => {}) или observer(() => {})
52
+ if (node.type === "CallExpression" && node.arguments.length > 0) {
53
+ return getComponentName(node.arguments[0]);
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ /** Проверяет, является ли функция React-компонентом */
60
+ function isReactComponent(node) {
61
+ const name = getComponentName(node);
62
+ return Boolean(name && isPascalCase(name));
63
+ }
64
+
65
+ /** Находит корневой JSX элемент компонента, поддержка HOC */
66
+ function getRootJSXFromComponent(fnNode) {
67
+ if (!fnNode) return null;
68
+
69
+ // Разворачиваем HOC
70
+ if (fnNode.type === "CallExpression" && fnNode.arguments.length > 0) {
71
+ return getRootJSXFromComponent(fnNode.arguments[0]);
72
+ }
73
+
74
+ // Arrow function без блока: const X = () => <Page />
75
+ if (
76
+ fnNode.body?.type === "JSXElement" ||
77
+ fnNode.body?.type === "JSXFragment"
78
+ ) {
79
+ return fnNode.body;
80
+ }
81
+
82
+ // Function с блоком
83
+ if (fnNode.body?.type !== "BlockStatement") return null;
84
+
85
+ for (const stmt of fnNode.body.body) {
50
86
  if (
51
- parentType === "ExportDefaultDeclaration" ||
52
- parentType === "ExportNamedDeclaration"
87
+ stmt.type === "ReturnStatement" &&
88
+ stmt.argument &&
89
+ (stmt.argument.type === "JSXElement" ||
90
+ stmt.argument.type === "JSXFragment")
53
91
  ) {
54
- return true;
92
+ return stmt.argument;
55
93
  }
56
- current = current.parent;
57
94
  }
58
- return false;
95
+
96
+ return null;
59
97
  }
60
98
 
61
- /**
62
- * Спускается от Fragment к первому рендеримому JSXElement
63
- */
99
+ /** Рекурсивно спускается через Fragment к первому рендеримому JSXElement */
64
100
  function descendToFirstRenderable(node) {
65
101
  if (!node) return null;
66
102
 
67
- // 1) Если это JSXFragment — обходим детей
68
103
  if (node.type === "JSXFragment") {
69
104
  for (const child of node.children || []) {
70
105
  if (child.type === "JSXElement") {
@@ -75,7 +110,6 @@ function descendToFirstRenderable(node) {
75
110
  return null;
76
111
  }
77
112
 
78
- // 2) Если это JSXElement с именем Fragment
79
113
  if (node.type === "JSXElement") {
80
114
  const name = getJSXName(node.openingElement?.name);
81
115
  if (name === "Fragment") {
@@ -87,32 +121,24 @@ function descendToFirstRenderable(node) {
87
121
  }
88
122
  return null;
89
123
  }
90
- // Иначе это реальный рендеримый элемент
91
124
  return node;
92
125
  }
93
126
 
94
127
  return null;
95
128
  }
96
129
 
97
- /**
98
- * Проверяет наличие data-testid или dataTestID атрибута
99
- */
130
+ /** Проверяет наличие data-testid или dataTestID */
100
131
  function hasDataTestId(openingElement) {
101
- if (!openingElement || !openingElement.attributes) {
102
- return false;
103
- }
132
+ if (!openingElement || !openingElement.attributes) return false;
133
+
104
134
  return openingElement.attributes.some((attr) => {
105
- if (!attr || attr.type !== "JSXAttribute" || !attr.name) {
106
- return false;
107
- }
135
+ if (!attr || attr.type !== "JSXAttribute" || !attr.name) return false;
108
136
  const attrName = attr.name.name;
109
137
  return attrName === "data-testid" || attrName === "dataTestID";
110
138
  });
111
139
  }
112
140
 
113
- /**
114
- * Получает относительный путь к файлу от корня проекта
115
- */
141
+ /** Получает относительный путь к файлу */
116
142
  function getRelativeFilePath(context) {
117
143
  const filename = context.getFilename();
118
144
  const workspaceRoot = context.getCwd ? context.getCwd() : process.cwd();
@@ -124,7 +150,7 @@ module.exports = {
124
150
  type: "suggestion",
125
151
  docs: {
126
152
  description:
127
- "Проверяет наличие data-testid атрибута у корневого контейнера JSX файла и рекомендует запустить codemod",
153
+ "Проверяет наличие data-testid атрибута у корневого JSX элемента React-компонента",
128
154
  category: "Best Practices",
129
155
  recommended: false,
130
156
  },
@@ -139,83 +165,37 @@ module.exports = {
139
165
  },
140
166
 
141
167
  create(context) {
142
- let hasReported = false;
143
- let rootJSXNode = null;
144
-
145
- return {
146
- ReturnStatement(node) {
147
- // Проверяем только один раз на файл
148
- if (hasReported || rootJSXNode) {
149
- return;
150
- }
151
-
152
- const returnArgument = node.argument;
153
- if (!returnArgument) {
154
- return;
155
- }
156
-
157
- // Если возвращается JSX элемент или Fragment
158
- if (
159
- returnArgument.type === "JSXElement" ||
160
- returnArgument.type === "JSXFragment"
161
- ) {
162
- // Проверяем, что это не вложенный JSX (не имеет JSX родителя)
163
- if (isTopLevelJSX(returnArgument)) {
164
- rootJSXNode = returnArgument;
165
- }
166
- }
167
- },
168
-
169
- JSXElement(node) {
170
- // Проверяем только один раз на файл
171
- if (hasReported || rootJSXNode) {
172
- return;
173
- }
174
-
175
- // Проверяем верхнеуровневые JSX элементы вне ReturnStatement
176
- // (например, экспортируемые напрямую)
177
- if (isTopLevelJSX(node)) {
178
- rootJSXNode = node;
179
- }
180
- },
168
+ const components = new Set();
181
169
 
182
- JSXFragment(node) {
183
- // Проверяем только один раз на файл
184
- if (hasReported || rootJSXNode) {
185
- return;
186
- }
170
+ function collectComponent(node) {
171
+ if (isReactComponent(node)) {
172
+ components.add(node);
173
+ }
174
+ }
187
175
 
188
- // Проверяем верхнеуровневые Fragment вне ReturnStatement
189
- if (isTopLevelJSX(node)) {
190
- rootJSXNode = node;
191
- }
192
- },
176
+ return {
177
+ FunctionDeclaration: collectComponent,
178
+ FunctionExpression: collectComponent,
179
+ ArrowFunctionExpression: collectComponent,
180
+ // CallExpression больше не нужен, HOC разворачиваются внутри getComponentName
181
+ //"CallExpression": collectComponent,
193
182
 
194
183
  "Program:exit"() {
195
- // После обхода всего файла проверяем корневой JSX элемент
196
- if (hasReported || !rootJSXNode) {
197
- return;
198
- }
199
-
200
- // Если это Fragment, спускаемся к первому рендеримому элементу
201
- const rootElement = descendToFirstRenderable(rootJSXNode);
202
- const elementToCheck = rootElement || rootJSXNode;
203
-
204
- if (!elementToCheck.openingElement) {
205
- return;
206
- }
207
-
208
- // Проверяем наличие data-testid
209
- if (!hasDataTestId(elementToCheck.openingElement)) {
210
- hasReported = true;
211
- const filePath = getRelativeFilePath(context);
212
- context.report({
213
- node: elementToCheck.openingElement,
214
- messageId: "missingDataTestId",
215
- data: {
216
- filePath,
217
- },
218
- });
184
+ for (const component of components) {
185
+ const rootJSX = getRootJSXFromComponent(component);
186
+ if (!rootJSX) continue;
187
+
188
+ const elementToCheck = descendToFirstRenderable(rootJSX) || rootJSX;
189
+ if (!elementToCheck?.openingElement) continue;
190
+
191
+ if (!hasDataTestId(elementToCheck.openingElement)) {
192
+ const filePath = getRelativeFilePath(context);
193
+ context.report({
194
+ node: elementToCheck.openingElement,
195
+ messageId: "missingDataTestId",
196
+ data: { filePath },
197
+ });
198
+ }
219
199
  }
220
200
  },
221
201
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-power-esrules",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "custom ESLint rules",
5
5
  "main": "index.js",
6
6
  "peerDependencies": {
@@ -17,7 +17,7 @@
17
17
  "lib": "lib"
18
18
  },
19
19
  "scripts": {
20
- "test": "echo \"Error: no test specified\" && exit 1"
20
+ "test": "jest"
21
21
  },
22
22
  "repository": {
23
23
  "type": "git",
@@ -27,5 +27,9 @@
27
27
  "bugs": {
28
28
  "url": "https://github.com/Vollmond148259/eslint-plugin-power-esrules/issues"
29
29
  },
30
- "homepage": "https://github.com/Vollmond148259/eslint-plugin-power-esrules#readme"
30
+ "homepage": "https://github.com/Vollmond148259/eslint-plugin-power-esrules#readme",
31
+ "devDependencies": {
32
+ "eslint": "^8.57.1",
33
+ "jest": "^29.3.1"
34
+ }
31
35
  }
@@ -0,0 +1,159 @@
1
+ const { RuleTester } = require("eslint");
2
+ const rule = require("../lib/rules/props-destructuring-sort");
3
+
4
+ const ruleTester = new RuleTester({
5
+ parserOptions: {
6
+ ecmaVersion: 2022,
7
+ sourceType: "module",
8
+ ecmaFeatures: { jsx: true },
9
+ },
10
+ });
11
+
12
+ ruleTester.run("props-destructuring-sort", rule, {
13
+ valid: [
14
+ {
15
+ name: "sorted destructuring from props in functional component",
16
+ code: `
17
+ function MyComponent(props) {
18
+ const { alpha, beta } = props;
19
+ return <div>{alpha}{beta}</div>;
20
+ }
21
+ `,
22
+ },
23
+ {
24
+ name: "single property from props",
25
+ code: `
26
+ function MyComponent(props) {
27
+ const { only } = props;
28
+ return <div>{only}</div>;
29
+ }
30
+ `,
31
+ },
32
+ {
33
+ name: "unsorted props outside React component",
34
+ code: `
35
+ function util(props) {
36
+ const { z, a } = props;
37
+ return z + a;
38
+ }
39
+ `,
40
+ },
41
+ {
42
+ name: "sorted params in arrow component",
43
+ code: `
44
+ const MyComponent = ({ alpha, beta }) => <div>{alpha}{beta}</div>;
45
+ `,
46
+ },
47
+ {
48
+ name: "sorted this.props in class component",
49
+ code: `
50
+ class MyComponent extends React.Component {
51
+ render() {
52
+ const { alpha, beta } = this.props;
53
+ return <div>{alpha}{beta}</div>;
54
+ }
55
+ }
56
+ `,
57
+ },
58
+ {
59
+ name: "rest element last when already sorted",
60
+ code: `
61
+ function MyComponent(props) {
62
+ const { alpha, beta, ...rest } = props;
63
+ return <div>{alpha}</div>;
64
+ }
65
+ `,
66
+ },
67
+ ],
68
+
69
+ invalid: [
70
+ {
71
+ name: "const from props in functional component",
72
+ code: `
73
+ function MyComponent(props) {
74
+ const { zebra, alpha } = props;
75
+ return <div>{zebra}{alpha}</div>;
76
+ }
77
+ `,
78
+ output: `
79
+ function MyComponent(props) {
80
+ const { alpha, zebra } = props;
81
+ return <div>{zebra}{alpha}</div>;
82
+ }
83
+ `,
84
+ errors: [{ messageId: "incorrectOrder" }],
85
+ },
86
+ {
87
+ name: "destructured params in function declaration",
88
+ code: `
89
+ function MyComponent({ zebra, alpha }) {
90
+ return <div>{zebra}{alpha}</div>;
91
+ }
92
+ `,
93
+ output: `
94
+ function MyComponent({ alpha, zebra }) {
95
+ return <div>{zebra}{alpha}</div>;
96
+ }
97
+ `,
98
+ errors: [{ messageId: "incorrectOrder" }],
99
+ },
100
+ {
101
+ name: "destructured params in arrow component with JSX",
102
+ code: `
103
+ const MyComponent = ({ zebra, alpha }) => <div>{zebra}{alpha}</div>;
104
+ `,
105
+ output: `
106
+ const MyComponent = ({ alpha, zebra }) => <div>{zebra}{alpha}</div>;
107
+ `,
108
+ errors: [{ messageId: "incorrectOrder" }],
109
+ },
110
+ {
111
+ name: "this.props in class component",
112
+ code: `
113
+ class MyComponent extends Component {
114
+ render() {
115
+ const { zebra, alpha } = this.props;
116
+ return <div>{zebra}{alpha}</div>;
117
+ }
118
+ }
119
+ `,
120
+ output: `
121
+ class MyComponent extends Component {
122
+ render() {
123
+ const { alpha, zebra } = this.props;
124
+ return <div>{zebra}{alpha}</div>;
125
+ }
126
+ }
127
+ `,
128
+ errors: [{ messageId: "incorrectOrder" }],
129
+ },
130
+ {
131
+ name: "rest stays last after sort",
132
+ code: `
133
+ function MyComponent(props) {
134
+ const { zebra, alpha, ...rest } = props;
135
+ return <div>{zebra}</div>;
136
+ }
137
+ `,
138
+ output: `
139
+ function MyComponent(props) {
140
+ const { alpha, zebra, ...rest } = props;
141
+ return <div>{zebra}</div>;
142
+ }
143
+ `,
144
+ errors: [{ messageId: "incorrectOrder" }],
145
+ },
146
+ {
147
+ name: "memo-wrapped component params",
148
+ code: `
149
+ const MyComponent = memo(({ zebra, alpha }) => <div>{zebra}</div>);
150
+ `,
151
+ output: `
152
+ const MyComponent = memo(({ alpha, zebra }) => <div>{zebra}</div>);
153
+ `,
154
+ errors: [{ messageId: "incorrectOrder" }],
155
+ },
156
+ ],
157
+ });
158
+
159
+ console.log("OK");
@@ -0,0 +1,112 @@
1
+ const { RuleTester } = require("eslint");
2
+ const rule = require("../lib/rules/require-data-testid");
3
+
4
+ const ruleTester = new RuleTester({
5
+ parserOptions: {
6
+ ecmaVersion: 2022,
7
+ sourceType: "module",
8
+ ecmaFeatures: { jsx: true },
9
+ },
10
+ });
11
+
12
+ ruleTester.run("require-data-testid", rule, {
13
+ valid: [
14
+ {
15
+ name: "function declaration with data-testid",
16
+ code: `
17
+ function MyComponent() {
18
+ return <div data-testid="my-component">Hello</div>;
19
+ }
20
+ `,
21
+ },
22
+ {
23
+ name: "arrow component with dataTestID",
24
+ code: `
25
+ const MyComponent = () => <section dataTestID="section">Hi</section>;
26
+ `,
27
+ },
28
+ {
29
+ name: "memo-wrapped component with data-testid",
30
+ code: `
31
+ const MyComponent = memo(() => <div data-testid="memo">Memo</div>);
32
+ `,
33
+ },
34
+ {
35
+ name: "forwardRef component with data-testid",
36
+ code: `
37
+ const MyComponent = forwardRef((props, ref) => <div ref={ref} data-testid="ref">Ref</div>);
38
+ `,
39
+ },
40
+ {
41
+ name: "nested JSX inside Fragment",
42
+ code: `
43
+ const MyComponent = () => (
44
+ <>
45
+ <div data-testid="inside">Hello</div>
46
+ </>
47
+ );
48
+ `,
49
+ },
50
+ {
51
+ name: "non-PascalCase function is ignored",
52
+ code: `
53
+ function utilFunction() {
54
+ return <div>Hello</div>;
55
+ }
56
+ `,
57
+ },
58
+ ],
59
+
60
+ invalid: [
61
+ {
62
+ name: "function declaration without data-testid",
63
+ code: `
64
+ function MyComponent() {
65
+ return <div>Hello</div>;
66
+ }
67
+ `,
68
+ errors: [{ messageId: "missingDataTestId" }],
69
+ },
70
+ {
71
+ name: "arrow component missing data-testid",
72
+ code: `
73
+ const MyComponent = () => <section>Hi</section>;
74
+ `,
75
+ errors: [{ messageId: "missingDataTestId" }],
76
+ },
77
+ {
78
+ name: "memo-wrapped component missing data-testid",
79
+ code: `
80
+ const MyComponent = memo(() => <div>Memo</div>);
81
+ `,
82
+ errors: [{ messageId: "missingDataTestId" }],
83
+ },
84
+ {
85
+ name: "forwardRef component missing data-testid",
86
+ code: `
87
+ const MyComponent = forwardRef((props, ref) => <div ref={ref}>Ref</div>);
88
+ `,
89
+ errors: [{ messageId: "missingDataTestId" }],
90
+ },
91
+ {
92
+ name: "nested JSX in Fragment missing data-testid",
93
+ code: `
94
+ const MyComponent = () => (
95
+ <>
96
+ <div>Hello</div>
97
+ </>
98
+ );
99
+ `,
100
+ errors: [{ messageId: "missingDataTestId" }],
101
+ },
102
+ {
103
+ name: "HOC wrapped component without data-testid",
104
+ code: `
105
+ const MyComponent = observer(() => <div>Hello</div>);
106
+ `,
107
+ errors: [{ messageId: "missingDataTestId" }],
108
+ },
109
+ ],
110
+ });
111
+
112
+ console.log("OK");