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 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('path');
12
+ const path = require("path");
14
13
 
15
14
  /**
16
15
  * Проверяет, является ли класс React компонентом
17
16
  */
18
17
  function isReactComponentClass(node) {
19
- if (!node || (node.type !== 'ClassDeclaration' && node.type !== 'ClassExpression')) {
20
- return false;
21
- }
22
- if (!node.superClass) {
23
- return false;
24
- }
25
- const { superClass } = node;
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
- const lifecycleMethods = [
48
- 'componentDidMount',
49
- 'componentDidUpdate',
50
- 'componentWillUnmount',
51
- 'componentWillMount',
52
- 'componentWillReceiveProps',
53
- 'UNSAFE_componentWillMount',
54
- 'UNSAFE_componentWillReceiveProps',
55
- 'UNSAFE_componentWillUpdate',
56
- 'shouldComponentUpdate',
57
- 'componentWillUpdate',
58
- 'getSnapshotBeforeUpdate',
59
- 'componentDidCatch',
60
- 'getDerivedStateFromProps',
61
- ];
62
- return lifecycleMethods.includes(methodName) || methodName.startsWith('component');
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
- let hasState = false;
70
- // Используем обход AST для поиска this.state и this.setState
71
- function traverse(node) {
72
- if (!node || hasState) {
73
- return;
74
- }
75
- if (
76
- node.type === 'MemberExpression' &&
77
- node.object &&
78
- node.object.type === 'ThisExpression' &&
79
- node.property &&
80
- node.property.type === 'Identifier' &&
81
- node.property.name === 'state'
82
- ) {
83
- hasState = true;
84
- return;
85
- }
86
- if (
87
- node.type === 'CallExpression' &&
88
- node.callee &&
89
- node.callee.type === 'MemberExpression' &&
90
- node.callee.object &&
91
- node.callee.object.type === 'ThisExpression' &&
92
- node.callee.property &&
93
- node.callee.property.type === 'Identifier' &&
94
- node.callee.property.name === 'setState'
95
- ) {
96
- hasState = true;
97
- return;
98
- }
99
- // Рекурсивно обходим дочерние узлы
100
- for (const key in node) {
101
- if (key !== 'parent' && node[key] && typeof node[key] === 'object') {
102
- if (Array.isArray(node[key])) {
103
- node[key].forEach((child) => traverse(child));
104
- } else {
105
- traverse(node[key]);
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
- traverse(node);
111
- return hasState;
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
- let count = 0;
119
- if (!classBody || !classBody.body || !Array.isArray(classBody.body)) {
120
- return 0;
121
- }
122
- classBody.body.forEach((member) => {
123
- if (member.type === 'MethodDefinition') {
124
- const methodName = member.key && member.key.name;
125
- if (methodName === 'render' || methodName === 'constructor') {
126
- return;
127
- }
128
- if (!isLifecycleMethod(methodName)) {
129
- count++;
130
- }
131
- } else if (
132
- member.type === 'Property' ||
133
- member.type === 'ClassProperty' ||
134
- member.type === 'PropertyDefinition'
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
- const methodName = member.key && member.key.name;
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
- return count;
159
+ return count;
152
160
  }
153
161
 
154
162
  /**
155
163
  * Получает относительный путь к файлу от корня проекта
156
164
  */
157
165
  function getRelativeFilePath(context) {
158
- const filename = context.getFilename();
159
- const workspaceRoot = context.getCwd ? context.getCwd() : process.cwd();
160
- return path.relative(workspaceRoot, filename);
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
- meta: {
165
- type: 'suggestion',
166
- docs: {
167
- description:
168
- 'Определяет классовые компоненты для конвертации в функциональные и рекомендует запустить codemod',
169
- category: 'Best Practices',
170
- recommended: false,
171
- },
172
- fixable: null,
173
- schema: [],
174
- messages: {
175
- shouldConvertToFunctional:
176
- 'Классовый компонент "{{componentName}}" подходит для конвертации в функциональный. ' +
177
- 'Запустите: npm run codemod:classToFC -- {{filePath}}',
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
- create(context) {
182
- return {
183
- ClassDeclaration(node) {
184
- if (!isReactComponentClass(node)) {
185
- return;
186
- }
187
- const componentName = node.id ? node.id.name : 'Unknown';
188
- // Проверяем использование this.state
189
- if (hasStateUsage(node)) {
190
- return;
191
- }
192
- const methodCount = countNonLifecycleMethods(node.body);
193
- if (methodCount >= 1 && methodCount <= 4) {
194
- const filePath = getRelativeFilePath(context);
195
- context.report({
196
- node: node.id || node,
197
- messageId: 'shouldConvertToFunctional',
198
- data: {
199
- componentName,
200
- filePath,
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
- ClassExpression(node) {
207
- if (!isReactComponentClass(node)) {
208
- return;
209
- }
210
- let componentName = 'Unknown';
211
- const { parent } = node;
212
- if (parent && parent.type === 'VariableDeclarator' && parent.id) {
213
- componentName = parent.id.name;
214
- } else if (parent && parent.type === 'ExportDefaultDeclaration') {
215
- componentName = 'DefaultExport';
216
- }
217
- // Проверяем использование this.state
218
- if (hasStateUsage(node)) {
219
- return;
220
- }
221
- const methodCount = countNonLifecycleMethods(node.body);
222
- if (methodCount >= 1 && methodCount <= 4) {
223
- const filePath = getRelativeFilePath(context);
224
- context.report({
225
- node: node.parent && node.parent.id ? node.parent.id : node,
226
- messageId: 'shouldConvertToFunctional',
227
- data: {
228
- componentName,
229
- filePath,
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
- 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';
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
- const internalPathPatterns = [
41
- /^components\//,
42
- /^utils\//,
43
- /^modules\//,
44
- /^pages\//,
45
- /^services\//,
46
- /^store\//,
47
- /^types\//,
48
- /^constants\//,
49
- /^hooks\//,
50
- /^helpers\//,
51
- ];
52
- const isInternalPath = internalPathPatterns.some((pattern) => pattern.test(source));
53
- if (!isInternalPath) {
54
- return 'external';
55
- }
56
- // Внутренние пути
57
- // Компоненты обычно из папки components/ или имеют имена с большой буквы
58
- if (source.startsWith('components/')) {
59
- // Исключения: theme, constants, utils, helpers - это утилиты, не компоненты
60
- const isUtility = /components\/(theme|constants|utils|helpers|auxiliary)/.test(source);
61
- if (isUtility) {
62
- return 'internal';
63
- }
64
- // Проверяем, является ли это компонентом
65
- const isDefaultImport = node.specifiers.some(
66
- (spec) => spec.type === 'ImportDefaultSpecifier'
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
- (spec) =>
82
- spec.type === 'ImportSpecifier' &&
83
- spec.imported &&
84
- /^[A-Z][a-zA-Z]*$/.test(spec.imported.name) &&
85
- spec.imported.name !== spec.imported.name.toUpperCase() // Не константа
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
- return 'component';
80
+ if (isDefaultImport || hasComponentName) {
81
+ return "component";
89
82
  }
90
- return 'internal';
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
- const priorities = {
98
- external: 1,
99
- internal: 2,
100
- component: 3,
101
- };
102
- return priorities[type] || 999;
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
- const typeA = getImportType(a);
110
- const typeB = getImportType(b);
111
- const priorityA = getImportPriority(typeA);
112
- const priorityB = getImportPriority(typeB);
113
- // Сначала по типу
114
- if (priorityA !== priorityB) {
115
- return priorityA - priorityB;
116
- }
117
- // Затем по имени источника (алфавитно)
118
- const sourceA = a.source.value;
119
- const sourceB = b.source.value;
120
- return sourceA.localeCompare(sourceB);
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
- const typeA = getImportType(importA);
128
- const typeB = getImportType(importB);
129
- return typeA !== typeB;
132
+ const typeA = getImportType(importA);
133
+ const typeB = getImportType(importB);
134
+ return typeA !== typeB;
130
135
  }
131
136
 
132
137
  module.exports = {
133
- meta: {
134
- type: 'layout',
135
- docs: {
136
- description: 'Проверяет сортировку импортов согласно соглашению',
137
- category: 'Stylistic Issues',
138
- recommended: false,
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
- create(context) {
150
- const sourceCode = context.getSourceCode();
151
- return {
152
- Program(node) {
153
- const imports = node.body.filter((stmt) => stmt.type === 'ImportDeclaration');
154
-
155
- if (imports.length === 0) {
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
- for (let i = 1; i < imports.length; i++) {
202
- const prev = imports[i - 1];
203
- const current = imports[i];
204
- if (needsBlankLineBetween(prev, current)) {
205
- const firstTokenCurrent = sourceCode.getFirstToken(current);
206
- const lastTokenPrev = sourceCode.getLastToken(prev);
207
- const lineDiff = firstTokenCurrent.loc.start.line - lastTokenPrev.loc.end.line;
208
- const hasBlankLine = lineDiff > 1;
209
- if (!hasBlankLine) {
210
- context.report({
211
- node: current,
212
- messageId: 'missingBlankLine',
213
- fix(fixer) {
214
- const firstToken = sourceCode.getFirstToken(current);
215
- return fixer.insertTextBefore(firstToken, '\n');
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-power-esrules",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "custom ESLint rules",
5
5
  "main": "index.js",
6
6
  "peerDependencies": {