power-linter 0.1.0

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/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # power-linter
2
+
3
+ power-linter — линтер (ESLint) и форматтер (Prettier) с расширяемой архитектурой для произвольных правил и скриптов.
4
+
5
+ - конфиги eslint и prettier из коробки
6
+ - кастомные правила-линтера (see `eslint-plugin-power-esrules`)
7
+
8
+ ## Использование
9
+
10
+ 1. Установка:
11
+
12
+ ```bash
13
+ npm install power-linter --save-dev
14
+ ```
15
+
16
+ 2. Добавьте экспорт конфигурации eslint
17
+
18
+ ```js
19
+ // .eslintrc.js
20
+ module.exports = require('power-linter/src/eslint/config');
21
+ ```
22
+
23
+ 3. Добавьте экспорт конфигурации Prettier
24
+
25
+ ```js
26
+ // .prettierrc.js
27
+ module.exports = require('power-linter/src/prettier/config');
28
+ ```
29
+
30
+ 4. Для кастомных правил добавляйте файлы в `eslint-plugin-power-esrules`
31
+
32
+ 5. Добавьте в корневой каталог проекта настройки vscode в каталоге .vscode создайте файл settings.json и добавьте в него
33
+
34
+ `{
35
+ "eslint.enable": true,
36
+ "eslint.validate": [
37
+ "javascript",
38
+ "javascriptreact",
39
+ "typescript",
40
+ "typescriptreact"
41
+ ],
42
+ "eslint.run": "onType",
43
+ "editor.codeActionsOnSave": {
44
+ "source.fixAll.eslint": "explicit"
45
+ },
46
+ "editor.formatOnSave": true,
47
+ "[javascript]": {
48
+ "editor.defaultFormatter": "dbaeumer.vscode-eslint",
49
+ "editor.formatOnSave": true
50
+ },
51
+ "[javascriptreact]": {
52
+ "editor.defaultFormatter": "dbaeumer.vscode-eslint",
53
+ "editor.formatOnSave": true
54
+ },
55
+ "[typescript]": {
56
+ "editor.defaultFormatter": "dbaeumer.vscode-eslint",
57
+ "editor.formatOnSave": true
58
+ },
59
+ "[typescriptreact]": {
60
+ "editor.defaultFormatter": "dbaeumer.vscode-eslint",
61
+ "editor.formatOnSave": true
62
+ },
63
+ "eslint.lintTask.enable": true,
64
+ "eslint.workingDirectories": [
65
+ {
66
+ "mode": "auto"
67
+ }
68
+ ]
69
+ }`
70
+
71
+ ---
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "power-linter",
3
+ "version": "0.1.0",
4
+ "description": "power-linter — линтер (ESLint) и форматтер (Prettier) с расширяемой архитектурой для произвольных правил и скриптов.",
5
+ "main": "src/index.js",
6
+ "author": "vollmond148",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "eslint",
10
+ "prettier",
11
+ "linter",
12
+ "config",
13
+ "eslint-plugin",
14
+ "eslint-config",
15
+ "eslint-rules",
16
+ "custom-rules",
17
+ "code-style"
18
+ ],
19
+ "dependencies": {
20
+ "@babel/eslint-parser": "^7.19.1",
21
+ "eslint": "^8.56.0",
22
+ "eslint-config-airbnb": "^19.0.4",
23
+ "eslint-config-prettier": "^8.5.0",
24
+ "eslint-plugin-import": "^2.26.0",
25
+ "eslint-plugin-jsx-a11y": "^6.6.0",
26
+ "eslint-plugin-power-esrules": "^0.1.4",
27
+ "eslint-plugin-react": "^7.30.1",
28
+ "eslint-plugin-react-hooks": "^4.6.0",
29
+ "prettier": "^3.2.5"
30
+ },
31
+ "peerDependencies": {
32
+ "eslint": "^8.0.0"
33
+ },
34
+ "files": [
35
+ "src/",
36
+ "scripts/"
37
+ ]
38
+ }
@@ -0,0 +1,186 @@
1
+ /* eslint-disable max-len */
2
+ /**
3
+ * Codemod: Добавляет уникальный data-testid только к внешнему визуальному JSX элементу.
4
+ * Если верхний узел — Fragment, спускается к первому реальному JSXElement.
5
+ *
6
+ * Использование через power-linter:
7
+ * node node_modules/power-linter/scripts/addDataTestId/run-codemod.js <path>
8
+ */
9
+
10
+ // const fs = require('fs');
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+
14
+ module.exports.parser = "tsx";
15
+
16
+ // const DATA_FILE_PATH = path.resolve('./scripts/addDataId/data-testids.json');
17
+ const IGNORED_IMPORTED_COMPONENTS = [
18
+ "div",
19
+ "span",
20
+ "SvgRoot",
21
+ "TooltipContentRoot",
22
+ "Svg",
23
+ "Field",
24
+ ];
25
+ const COMPONENTS_FOR_TEST_ID_PROP = ["Field"];
26
+
27
+ function md5short(value) {
28
+ return crypto.createHash("md5").update(value).digest("hex").slice(0, 6);
29
+ }
30
+
31
+ // Имя JSX: Identifier / MemberExpression / NamespacedName
32
+ function getJSXName(jsxName) {
33
+ if (!jsxName) return null;
34
+ switch (jsxName.type) {
35
+ case "JSXIdentifier":
36
+ return jsxName.name || null;
37
+ case "JSXMemberExpression":
38
+ // Берем правую часть: UI.Button -> Button
39
+ return getJSXName(jsxName.property);
40
+ case "JSXNamespacedName":
41
+ return `${jsxName.namespace?.name || "ns"}:${jsxName.name?.name || "Name"}`;
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // Проверка наличия родительского JSX узла (Element или Fragment)
48
+ function hasJSXAncestor(pathNode) {
49
+ let p = pathNode.parentPath;
50
+ while (p) {
51
+ const t = p.node && p.node.type;
52
+ if (t === "JSXElement" || t === "JSXFragment") return true;
53
+ p = p.parentPath;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ // Спуститься от Fragment к первому рендеримому JSXElement
59
+ function descendToFirstRenderable(j, node, root) {
60
+ // node: JSXElement (в т.ч. React.Fragment) или JSXFragment
61
+ if (!node) return null;
62
+
63
+ // 1) Если это JSXFragment — обходим детей
64
+ if (node.type === "JSXFragment") {
65
+ for (const ch of node.children || []) {
66
+ if (ch.type === "JSXElement") {
67
+ const resolved = descendToFirstRenderable(j, ch, root);
68
+ if (resolved) return resolved;
69
+ }
70
+ // Пропускаем JSXText/JSXExpressionContainer и т.п.
71
+ }
72
+ return null; // нет рендеримого элемента
73
+ }
74
+
75
+ // 2) Если это JSXElement с именем Fragment (включая React.Fragment)
76
+ if (node.type === "JSXElement") {
77
+ const nm = getJSXName(node.openingElement?.name);
78
+ if (nm === "Fragment" || checkIsImported(root, j, nm)) {
79
+ // Это <Fragment>...</Fragment>
80
+ for (const ch of node.children || []) {
81
+ if (ch.type === "JSXElement") {
82
+ const resolved = descendToFirstRenderable(j, ch, root);
83
+ if (resolved) return resolved;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ // Иначе это реальный рендеримый элемент
89
+ return node;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ function hasLocatorAttr(openingEl) {
96
+ return (openingEl.attributes || []).some((attr) => {
97
+ if (!attr || attr.type !== "JSXAttribute" || !attr.name) return false;
98
+ const n = attr.name.name;
99
+ return n === "data-testid" || n === "dataTestID";
100
+ });
101
+ }
102
+
103
+ function checkIsImported(root, j, name) {
104
+ // Для обычных html-тегов или 'Root' — не проверяем импорт (считаем всегда "true")
105
+ if (!name || IGNORED_IMPORTED_COMPONENTS.includes(name)) {
106
+ return false;
107
+ }
108
+
109
+ // Найдём все import и require декларации
110
+ const imports = root.find(j.ImportDeclaration);
111
+ // Проверим, импортируется ли нужное имя (или как дефолт, или как имя)
112
+ const isImported = imports.some((path) =>
113
+ path.node.specifiers.some(
114
+ (spec) =>
115
+ (spec.type === "ImportDefaultSpecifier" ||
116
+ spec.type === "ImportSpecifier") &&
117
+ spec.local &&
118
+ spec.local.name === name
119
+ )
120
+ );
121
+
122
+ return isImported;
123
+ }
124
+
125
+ module.exports = function transformer(fileInfo, api) {
126
+ const j = api.jscodeshift;
127
+ const root = j(fileInfo.source);
128
+
129
+ const absPath = fileInfo.path;
130
+ const relPath = path.relative(process.cwd(), absPath);
131
+ const fileBaseName = path.basename(relPath, path.extname(relPath));
132
+
133
+ // const registry = {};
134
+ // if (fs.existsSync(DATA_FILE_PATH)) {
135
+ // try {
136
+ // registry = JSON.parse(fs.readFileSync(DATA_FILE_PATH, 'utf8'));
137
+ // } catch {
138
+ // registry = {};
139
+ // }
140
+ // }
141
+ // Ищем «верхние» JSX узлы (тут можно не искать верхние узлы тогда будет сильно больше элементов)
142
+ const topLevelJSX = root
143
+ .find(j.JSXElement)
144
+ .filter((p) => !hasJSXAncestor(p))
145
+ .nodes();
146
+
147
+ // Переберм массив элементов
148
+ topLevelJSX.forEach((candidate) => {
149
+ // Если фрагмент или Fragment — спустимся до первого реального элемента
150
+ const realRoot = descendToFirstRenderable(j, candidate, root);
151
+ if (!realRoot || !realRoot.openingElement) {
152
+ // Нечего помечать
153
+ return;
154
+ }
155
+
156
+ const opening = realRoot.openingElement;
157
+ // Имя компонента/тега (div / Button / UI.Button -> Button)
158
+ const name = getJSXName(opening.name);
159
+ // Проверяем, импортируется ли этот компонент в файле
160
+ const isImported = checkIsImported(root, j, name);
161
+ const isInsertProp = COMPONENTS_FOR_TEST_ID_PROP.includes(name);
162
+
163
+ if (!isImported && !hasLocatorAttr(opening)) {
164
+ // Собираем детерминированный короткий хеш: путь + имя
165
+ const uniqueId = `${fileBaseName}_${name}_${md5short(`${relPath}::${name}`)}`;
166
+
167
+ // Вешаем data-testid
168
+ opening.attributes = opening.attributes || [];
169
+ const testIDName = isInsertProp ? "dataTestID" : "data-testid";
170
+ opening.attributes.push(
171
+ j.jsxAttribute(j.jsxIdentifier(testIDName), j.literal(uniqueId))
172
+ );
173
+
174
+ // Обновляем реестр: храним уникальный список ID на файл
175
+ // const prev = Array.isArray(registry[relPath]) ? registry[relPath] : [];
176
+ // const nextSet = new Set(prev);
177
+ // nextSet.add(uniqueId);
178
+ // registry[relPath] = Array.from(nextSet);
179
+ }
180
+ });
181
+ // Пишем реестр один раз на файл
182
+ // для записи в json нужно раскоментировать fs , DATA_FILE_PATH, логику заполенения и обновления registry
183
+ // fs.writeFileSync(DATA_FILE_PATH, JSON.stringify(registry, null, 2), 'utf8');
184
+
185
+ return root.toSource({ quote: "single" });
186
+ };
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Обертка для запуска codemod скрипта add-Data-testid.js
5
+ *
6
+ * Использование:
7
+ * node node_modules/power-linter/scripts/addDataTestId/run-codemod.js <path-or-directory>
8
+ *
9
+ */
10
+
11
+ const { execSync } = require("child_process");
12
+ const path = require("path");
13
+ const fs = require("fs");
14
+
15
+ const targetPath = process.argv[2];
16
+ if (!targetPath) {
17
+ console.error(
18
+ "Ошибка: Укажите путь к файлу или директории для преобразования"
19
+ );
20
+ console.error(
21
+ "Использование: node node_modules/power-linter/scripts/addDataTestId/run-codemod.js <path-or-directory>"
22
+ );
23
+ process.exit(1);
24
+ }
25
+
26
+ // Определяем корень проекта
27
+ const currentDir = process.cwd();
28
+ const isPowerLinterDir =
29
+ currentDir.endsWith("power-linter") ||
30
+ path.basename(currentDir) === "power-linter";
31
+ const projectRoot = isPowerLinterDir ? path.join(currentDir, "..") : currentDir;
32
+ const absolutePath = path.isAbsolute(targetPath)
33
+ ? targetPath
34
+ : path.join(projectRoot, targetPath);
35
+
36
+ if (!fs.existsSync(absolutePath)) {
37
+ console.error(`Ошибка: Путь не найден: ${absolutePath}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ const codemodPath = path.join(__dirname, "add-data-testid.js");
42
+ const stats = fs.statSync(absolutePath);
43
+ const isDirectory = stats.isDirectory();
44
+
45
+ // eslint-disable-next-line no-console
46
+ console.log(`Преобразование: ${targetPath}`);
47
+ // eslint-disable-next-line no-console
48
+ console.log(`Использование codemod: ${codemodPath}`);
49
+
50
+ try {
51
+ let command;
52
+ if (isDirectory) {
53
+ // Для директории используем те же параметры, что и в оригинальном скрипте
54
+ command = `npx jscodeshift -t "${codemodPath}" "${absolutePath}" --extensions=js,jsx,ts,tsx --parser=tsx`;
55
+ } else {
56
+ // Для одного файла
57
+ const ext = path.extname(absolutePath);
58
+ if (![".js", ".jsx", ".ts", ".tsx"].includes(ext)) {
59
+ console.error(
60
+ `Ошибка: Файл должен иметь расширение .js, .jsx, .ts или .tsx`
61
+ );
62
+ process.exit(1);
63
+ }
64
+ command = `npx jscodeshift -t "${codemodPath}" "${absolutePath}" --parser=tsx`;
65
+ }
66
+
67
+ // eslint-disable-next-line no-console
68
+ console.log(`Выполнение: ${command}`);
69
+ execSync(command, {
70
+ stdio: "inherit",
71
+ cwd: projectRoot,
72
+ });
73
+ // eslint-disable-next-line no-console
74
+ console.log("\n✓ Преобразование завершено успешно!");
75
+ } catch (error) {
76
+ console.error("\n✗ Ошибка при выполнении преобразования:", error.message);
77
+ process.exit(1);
78
+ }
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Codemod для преобразования классовых React компонентов в функциональные
3
+ *
4
+ * Условия применения:
5
+ * - Компонент не использует this.state
6
+ * - Компонент имеет от 1 до 4 методов класса
7
+ * - Компонент не имеет сложных lifecycle методов
8
+ *
9
+ * Использование:
10
+ * node node_modules/power-linter/scripts/classToFC/run-codemod.js <file-path>
11
+ */
12
+
13
+ /**
14
+ * Проверяет, является ли класс React компонентом
15
+ */
16
+ function isReactComponentClass(node) {
17
+ if (
18
+ !node ||
19
+ (node.type !== "ClassDeclaration" && node.type !== "ClassExpression")
20
+ ) {
21
+ return false;
22
+ }
23
+ if (!node.superClass) {
24
+ return false;
25
+ }
26
+ const { superClass } = node;
27
+ if (superClass.type === "Identifier") {
28
+ return (
29
+ superClass.name === "Component" || superClass.name === "PureComponent"
30
+ );
31
+ }
32
+ if (
33
+ superClass.type === "MemberExpression" &&
34
+ superClass.object &&
35
+ superClass.object.type === "Identifier" &&
36
+ superClass.object.name === "React" &&
37
+ superClass.property &&
38
+ superClass.property.type === "Identifier" &&
39
+ (superClass.property.name === "Component" ||
40
+ superClass.property.name === "PureComponent")
41
+ ) {
42
+ return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Проверяет, содержит ли узел использование this.state или this.setState
49
+ */
50
+ function hasStateUsage(node, j) {
51
+ let hasState = false;
52
+ j(node)
53
+ .find(j.MemberExpression, {
54
+ object: { type: "ThisExpression" },
55
+ property: { name: "state" },
56
+ })
57
+ .forEach(() => {
58
+ hasState = true;
59
+ });
60
+ j(node)
61
+ .find(j.CallExpression, {
62
+ callee: {
63
+ type: "MemberExpression",
64
+ object: { type: "ThisExpression" },
65
+ property: { name: "setState" },
66
+ },
67
+ })
68
+ .forEach(() => {
69
+ hasState = true;
70
+ });
71
+ return hasState;
72
+ }
73
+
74
+ /**
75
+ * Получает имя компонента
76
+ */
77
+ function getComponentName(node, j, path) {
78
+ if (node.type === "ClassDeclaration" && node.id) {
79
+ return node.id.name;
80
+ }
81
+ if (node.type === "ClassExpression") {
82
+ let currentPath = path;
83
+ while (currentPath) {
84
+ const { parent } = currentPath;
85
+ if (parent && parent.value) {
86
+ if (parent.value.type === "VariableDeclarator" && parent.value.id) {
87
+ return parent.value.id.name;
88
+ }
89
+ if (parent.value.type === "ExportDefaultDeclaration") {
90
+ return null;
91
+ }
92
+ }
93
+ currentPath = parent;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Проверяет, является ли метод методом жизненного цикла
101
+ */
102
+ function isLifecycleMethod(methodName) {
103
+ const lifecycleMethods = [
104
+ "componentDidMount",
105
+ "componentDidUpdate",
106
+ "componentWillUnmount",
107
+ "componentWillMount",
108
+ "componentWillReceiveProps",
109
+ "UNSAFE_componentWillMount",
110
+ "UNSAFE_componentWillReceiveProps",
111
+ "UNSAFE_componentWillUpdate",
112
+ "shouldComponentUpdate",
113
+ "getSnapshotBeforeUpdate",
114
+ ];
115
+ return (
116
+ lifecycleMethods.includes(methodName) || methodName.startsWith("component")
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Преобразует методы класса в функции внутри компонента
122
+ */
123
+ function convertMethodsToFunctions(classBody) {
124
+ const methods = [];
125
+ const lifecycleMethods = [];
126
+ const staticProperties = [];
127
+ classBody.body.forEach((member) => {
128
+ if (member.type === "MethodDefinition") {
129
+ const methodName = member.key.name;
130
+ if (methodName === "render" || methodName === "constructor") {
131
+ return;
132
+ }
133
+ if (isLifecycleMethod(methodName)) {
134
+ lifecycleMethods.push(member);
135
+ } else {
136
+ methods.push(member);
137
+ }
138
+ } else if (member.type === "Property" || member.type === "ClassProperty") {
139
+ if (member.static) {
140
+ staticProperties.push(member);
141
+ } else if (member.key && member.key.name !== "render") {
142
+ const methodName = member.key.name;
143
+ if (isLifecycleMethod(methodName)) {
144
+ lifecycleMethods.push(member);
145
+ } else {
146
+ methods.push(member);
147
+ }
148
+ }
149
+ }
150
+ });
151
+ return { methods, lifecycleMethods, staticProperties };
152
+ }
153
+
154
+ /**
155
+ * Заменяет this.props на props в узле
156
+ */
157
+ function replaceThisProps(node, j) {
158
+ j(node)
159
+ .find(j.MemberExpression, {
160
+ object: { type: "ThisExpression" },
161
+ property: { name: "props" },
162
+ })
163
+ .replaceWith(() => j.identifier("props"));
164
+ }
165
+
166
+ /**
167
+ * Заменяет this.methodName на methodName
168
+ */
169
+ function replaceThisMethods(node, methodNames, j) {
170
+ methodNames.forEach((methodName) => {
171
+ j(node)
172
+ .find(j.MemberExpression, {
173
+ object: { type: "ThisExpression" },
174
+ property: { name: methodName },
175
+ })
176
+ .replaceWith(() => j.identifier(methodName));
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Преобразует методы жизненного цикла в хуки React
182
+ */
183
+ function convertLifecycleMethodsToHooks(lifecycleMethods, j) {
184
+ const hooks = [];
185
+ let needsUseEffect = false;
186
+ lifecycleMethods.forEach((method) => {
187
+ const methodName = method.key.name;
188
+ let methodBody;
189
+ if (method.type === "MethodDefinition") {
190
+ methodBody = method.value.body;
191
+ } else if (method.type === "Property" || method.type === "ClassProperty") {
192
+ methodBody = method.value.body;
193
+ }
194
+ if (!methodBody) {
195
+ return;
196
+ }
197
+ replaceThisProps(methodBody, j);
198
+ if (methodName === "componentDidMount") {
199
+ needsUseEffect = true;
200
+ hooks.push(
201
+ j.expressionStatement(
202
+ j.callExpression(j.identifier("useEffect"), [
203
+ j.arrowFunctionExpression([], methodBody),
204
+ j.arrayExpression([]),
205
+ ])
206
+ )
207
+ );
208
+ } else if (methodName === "componentWillUnmount") {
209
+ needsUseEffect = true;
210
+ let cleanupFunction;
211
+ if (methodBody.type === "BlockStatement") {
212
+ cleanupFunction = j.arrowFunctionExpression([], methodBody);
213
+ } else {
214
+ cleanupFunction = j.arrowFunctionExpression(
215
+ [],
216
+ j.blockStatement([j.returnStatement(methodBody)])
217
+ );
218
+ }
219
+ const effectBody = j.blockStatement([j.returnStatement(cleanupFunction)]);
220
+ hooks.push(
221
+ j.expressionStatement(
222
+ j.callExpression(j.identifier("useEffect"), [
223
+ j.arrowFunctionExpression([], effectBody),
224
+ j.arrayExpression([]),
225
+ ])
226
+ )
227
+ );
228
+ } else if (methodName === "componentDidUpdate") {
229
+ needsUseEffect = true;
230
+ const params =
231
+ method.type === "MethodDefinition"
232
+ ? method.value.params
233
+ : method.value.params || [];
234
+ if (params.length > 0) {
235
+ hooks.push(
236
+ j.expressionStatement(
237
+ j.callExpression(j.identifier("useEffect"), [
238
+ j.arrowFunctionExpression([], methodBody),
239
+ j.arrayExpression([j.identifier("props")]),
240
+ ])
241
+ )
242
+ );
243
+ } else {
244
+ hooks.push(
245
+ j.expressionStatement(
246
+ j.callExpression(j.identifier("useEffect"), [
247
+ j.arrowFunctionExpression([], methodBody),
248
+ ])
249
+ )
250
+ );
251
+ }
252
+ } else if (
253
+ methodName === "UNSAFE_componentWillReceiveProps" ||
254
+ methodName === "componentWillReceiveProps"
255
+ ) {
256
+ needsUseEffect = true;
257
+ const params =
258
+ method.type === "MethodDefinition"
259
+ ? method.value.params
260
+ : method.value.params || [];
261
+ if (params.length > 0 && params[0].name) {
262
+ j(methodBody)
263
+ .find(j.Identifier, { name: params[0].name })
264
+ .replaceWith(() => j.identifier("props"));
265
+ }
266
+ hooks.push(
267
+ j.expressionStatement(
268
+ j.callExpression(j.identifier("useEffect"), [
269
+ j.arrowFunctionExpression([], methodBody),
270
+ j.arrayExpression([j.identifier("props")]),
271
+ ])
272
+ )
273
+ );
274
+ }
275
+ });
276
+
277
+ return { hooks, needsUseEffect };
278
+ }
279
+
280
+ /**
281
+ * Создает функциональный компонент из классового
282
+ */
283
+ function createFunctionalComponent(classNode, j, path) {
284
+ const className = getComponentName(classNode, j, path);
285
+ const classBody = classNode.body;
286
+ if (hasStateUsage(classNode, j)) {
287
+ return null;
288
+ }
289
+ const { methods, lifecycleMethods, staticProperties } =
290
+ convertMethodsToFunctions(classBody);
291
+ const renderMethod = classBody.body.find((member) => {
292
+ if (member.type === "MethodDefinition" && member.key.name === "render") {
293
+ return true;
294
+ }
295
+ return (
296
+ (member.type === "Property" || member.type === "ClassProperty") &&
297
+ member.key &&
298
+ member.key.name === "render"
299
+ );
300
+ });
301
+ if (!renderMethod) {
302
+ return null;
303
+ }
304
+ let renderBody;
305
+ if (renderMethod.type === "MethodDefinition") {
306
+ renderBody = renderMethod.value.body;
307
+ } else {
308
+ renderBody = renderMethod.value.body;
309
+ }
310
+ if (!renderBody) {
311
+ return null;
312
+ }
313
+ const { hooks, needsUseEffect } = convertLifecycleMethodsToHooks(
314
+ lifecycleMethods,
315
+ j
316
+ );
317
+ const componentStatements = [];
318
+ const methodNames = [];
319
+ componentStatements.push(...hooks);
320
+ methods.forEach((method) => {
321
+ const methodName = method.key.name;
322
+ methodNames.push(methodName);
323
+ let methodBody;
324
+ let isArrow = false;
325
+ let params = [];
326
+ if (method.type === "MethodDefinition") {
327
+ methodBody = method.value.body;
328
+ params = method.value.params || [];
329
+ } else if (method.type === "Property" || method.type === "ClassProperty") {
330
+ if (method.value.type === "ArrowFunctionExpression") {
331
+ methodBody = method.value.body;
332
+ params = method.value.params || [];
333
+ isArrow = true;
334
+ } else {
335
+ methodBody = method.value.body;
336
+ params = method.value.params || [];
337
+ }
338
+ }
339
+ if (methodBody) {
340
+ replaceThisProps(methodBody, j);
341
+ replaceThisMethods(methodBody, methodNames, j);
342
+ if (
343
+ isArrow ||
344
+ method.type === "Property" ||
345
+ method.type === "ClassProperty"
346
+ ) {
347
+ componentStatements.push(
348
+ j.variableDeclaration("const", [
349
+ j.variableDeclarator(
350
+ j.identifier(methodName),
351
+ j.arrowFunctionExpression(params, methodBody)
352
+ ),
353
+ ])
354
+ );
355
+ } else {
356
+ componentStatements.push(
357
+ j.variableDeclaration("const", [
358
+ j.variableDeclarator(
359
+ j.identifier(methodName),
360
+ j.functionExpression(null, params, methodBody)
361
+ ),
362
+ ])
363
+ );
364
+ }
365
+ }
366
+ });
367
+ replaceThisProps(renderBody, j);
368
+ replaceThisMethods(renderBody, methodNames, j);
369
+ let componentBody;
370
+ if (renderBody.type === "BlockStatement") {
371
+ componentBody = j.blockStatement([
372
+ ...componentStatements,
373
+ ...renderBody.body,
374
+ ]);
375
+ } else {
376
+ componentBody = j.blockStatement([
377
+ ...componentStatements,
378
+ j.returnStatement(renderBody),
379
+ ]);
380
+ }
381
+ const componentParams = [j.identifier("props")];
382
+ const arrowFunction = j.arrowFunctionExpression(
383
+ componentParams,
384
+ componentBody
385
+ );
386
+ let componentDeclaration;
387
+ if (className) {
388
+ componentDeclaration = j.variableDeclaration("const", [
389
+ j.variableDeclarator(j.identifier(className), arrowFunction),
390
+ ]);
391
+ } else {
392
+ componentDeclaration = j.variableDeclaration("const", [
393
+ j.variableDeclarator(j.identifier("Component"), arrowFunction),
394
+ ]);
395
+ }
396
+ const result = [componentDeclaration];
397
+ if (staticProperties.length > 0 && className) {
398
+ staticProperties.forEach((staticProp) => {
399
+ const propName = staticProp.key.name;
400
+ result.push(
401
+ j.expressionStatement(
402
+ j.assignmentExpression(
403
+ "=",
404
+ j.memberExpression(j.identifier(className), j.identifier(propName)),
405
+ staticProp.value
406
+ )
407
+ )
408
+ );
409
+ });
410
+ }
411
+ return { result, needsUseEffect };
412
+ }
413
+
414
+ module.exports = function transformer(file, api) {
415
+ const j = api.jscodeshift;
416
+ const root = j(file.source);
417
+ let hasTransformations = false;
418
+ let needsUseEffect = false;
419
+ root.find(j.ClassDeclaration).forEach((path) => {
420
+ if (isReactComponentClass(path.node)) {
421
+ const transformed = createFunctionalComponent(path.node, j, path);
422
+ if (transformed) {
423
+ j(path).replaceWith(...transformed.result);
424
+ if (transformed.needsUseEffect) {
425
+ needsUseEffect = true;
426
+ }
427
+ hasTransformations = true;
428
+ }
429
+ }
430
+ });
431
+ root.find(j.ClassExpression).forEach((path) => {
432
+ if (isReactComponentClass(path.node)) {
433
+ const transformed = createFunctionalComponent(path.node, j, path);
434
+ if (transformed) {
435
+ const parent = path.parent.value;
436
+ if (parent && parent.type === "ReturnStatement") {
437
+ const arrowFunction = transformed.result[0].declarations[0].init;
438
+ j(path.parent).replaceWith(j.returnStatement(arrowFunction));
439
+ } else if (parent && parent.type === "VariableDeclarator") {
440
+ j(path.parent.parent).replaceWith(...transformed.result);
441
+ } else {
442
+ j(path).replaceWith(...transformed.result);
443
+ }
444
+ if (transformed.needsUseEffect) {
445
+ needsUseEffect = true;
446
+ }
447
+ hasTransformations = true;
448
+ }
449
+ }
450
+ });
451
+ root
452
+ .find(j.ImportDeclaration, {
453
+ source: { value: "react" },
454
+ })
455
+ .forEach((path) => {
456
+ const specifiers = path.value.specifiers || [];
457
+ const componentSpecifier = specifiers.find(
458
+ (s) => s.type === "ImportSpecifier" && s.imported.name === "Component"
459
+ );
460
+ const useEffectSpecifier = specifiers.find(
461
+ (s) => s.type === "ImportSpecifier" && s.imported.name === "useEffect"
462
+ );
463
+ if (needsUseEffect && !useEffectSpecifier) {
464
+ const newSpecifier = j.importSpecifier(j.identifier("useEffect"));
465
+ if (specifiers.length === 0) {
466
+ path.value.specifiers = [newSpecifier];
467
+ } else {
468
+ path.value.specifiers.push(newSpecifier);
469
+ }
470
+ }
471
+ if (componentSpecifier) {
472
+ const componentName = componentSpecifier.local.name;
473
+ const hasOtherUsage = root
474
+ .find(j.Identifier, { name: componentName })
475
+ .some((p) => {
476
+ const parent = p.parent.value;
477
+ return (
478
+ parent.type !== "ImportSpecifier" ||
479
+ (parent.type === "ImportSpecifier" &&
480
+ parent.imported.name !== "Component")
481
+ );
482
+ });
483
+ if (!hasOtherUsage) {
484
+ const newSpecifiers = specifiers.filter(
485
+ (s) => s !== componentSpecifier
486
+ );
487
+ if (newSpecifiers.length === 0) {
488
+ const hasDefault = specifiers.some(
489
+ (s) =>
490
+ s.type === "ImportDefaultSpecifier" ||
491
+ s.type === "ImportNamespaceSpecifier"
492
+ );
493
+ if (!hasDefault) {
494
+ j(path).remove();
495
+ } else {
496
+ path.value.specifiers = newSpecifiers;
497
+ }
498
+ } else {
499
+ path.value.specifiers = newSpecifiers;
500
+ }
501
+ }
502
+ }
503
+ });
504
+ if (needsUseEffect) {
505
+ const reactImports = root.find(j.ImportDeclaration, {
506
+ source: { value: "react" },
507
+ });
508
+ if (reactImports.length === 0) {
509
+ const useEffectImport = j.importDeclaration(
510
+ [j.importSpecifier(j.identifier("useEffect"))],
511
+ j.literal("react")
512
+ );
513
+ root.find(j.Program).get("body", 0).insertBefore(useEffectImport);
514
+ }
515
+ }
516
+ return hasTransformations
517
+ ? root.toSource({ quote: "single", trailingComma: true })
518
+ : null;
519
+ };
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Обертка для запуска codemod скрипта
5
+ *
6
+ * Использование:
7
+ * node node_modules/power-linter/scripts/classToFC/run-codemod.js <file-path>
8
+ */
9
+
10
+ const { execSync } = require("child_process");
11
+ const path = require("path");
12
+ const fs = require("fs");
13
+
14
+ const filePath = process.argv[2];
15
+ if (!filePath) {
16
+ console.error("Ошибка: Укажите путь к файлу для преобразования");
17
+ console.error("Использование: npm run codemod:classToFC -- <file-path>");
18
+ process.exit(1);
19
+ }
20
+ // Определяем корень проекта: если запускаемся из power-linter, используем родительскую директорию
21
+ const currentDir = process.cwd();
22
+ const isPowerLinterDir =
23
+ currentDir.endsWith("power-linter") ||
24
+ path.basename(currentDir) === "power-linter";
25
+ const projectRoot = isPowerLinterDir ? path.join(currentDir, "..") : currentDir;
26
+ const absolutePath = path.isAbsolute(filePath)
27
+ ? filePath
28
+ : path.join(projectRoot, filePath);
29
+ if (!fs.existsSync(absolutePath)) {
30
+ console.error(`Ошибка: Файл не найден: ${absolutePath}`);
31
+ process.exit(1);
32
+ }
33
+ const ext = path.extname(absolutePath);
34
+ if (![".js", ".jsx", ".ts", ".tsx"].includes(ext)) {
35
+ console.error(`Ошибка: Файл должен иметь расширение .js, .jsx, .ts или .tsx`);
36
+ process.exit(1);
37
+ }
38
+ const codemodPath = path.join(__dirname, "class-to-functional.js");
39
+ // eslint-disable-next-line no-console
40
+ console.log(`Преобразование файла: ${filePath}`);
41
+ // eslint-disable-next-line no-console
42
+ console.log(`Использование codemod: ${codemodPath}`);
43
+ try {
44
+ const command = `npx jscodeshift -t "${codemodPath}" "${absolutePath}"`;
45
+ // eslint-disable-next-line no-console
46
+ console.log(`Выполнение: ${command}`);
47
+ execSync(command, {
48
+ stdio: "inherit",
49
+ cwd: projectRoot,
50
+ });
51
+ // eslint-disable-next-line no-console
52
+ console.log("\n✓ Преобразование завершено успешно!");
53
+ // eslint-disable-next-line no-console
54
+ console.log("Проверьте файл и при необходимости внесите ручные исправления.");
55
+ } catch (error) {
56
+ console.error("\n✗ Ошибка при выполнении преобразования:", error.message);
57
+ process.exit(1);
58
+ }
@@ -0,0 +1,73 @@
1
+ module.exports = {
2
+ root: true,
3
+ env: {
4
+ browser: true,
5
+ node: true,
6
+ es2021: true,
7
+ },
8
+ extends: [
9
+ "airbnb",
10
+ "airbnb/hooks",
11
+ "eslint:recommended",
12
+ "prettier",
13
+ "plugin:power-esrules/recommended",
14
+ ],
15
+ plugins: ["power-esrules"],
16
+ parser: "@babel/eslint-parser",
17
+ parserOptions: {
18
+ requireConfigFile: false,
19
+ },
20
+ globals: {
21
+ document: true,
22
+ localStorage: true,
23
+ Headers: true,
24
+ fetch: true,
25
+ },
26
+ rules: {
27
+ "no-continue": 0,
28
+ "consistent-return": 0,
29
+ "no-constant-condition": 0,
30
+ "arrow-parens": 0,
31
+ "no-else-return": 0,
32
+ "global-require": 0,
33
+ "max-len": ["error", { code: 120, ignorePattern: "^import\\W.*" }],
34
+ "linebreak-style": 0,
35
+ indent: 0,
36
+ "prefer-template": ["warn"],
37
+ "no-restricted-syntax": 0,
38
+ "no-prototype-builtins": 0,
39
+ "no-param-reassign": ["error", { props: false }],
40
+ "comma-dangle": ["error", "only-multiline"],
41
+ "no-undef": 0,
42
+ "no-shadow": 0,
43
+ "no-plusplus": 0,
44
+ "arrow-body-style": ["error", "as-needed"],
45
+ "default-param-last": 0,
46
+ "class-methods-use-this": ["error", { enforceForClassFields: false }],
47
+ "no-use-before-define": [
48
+ "error",
49
+ { functions: false, classes: true, variables: true },
50
+ ],
51
+ "no-useless-escape": "warn",
52
+ "no-console": ["error", { allow: ["warn", "error"] }],
53
+ "func-names": 0,
54
+ "jsx-a11y/anchor-is-valid": ["warn"],
55
+ "jsx-a11y/no-static-element-interactions": 0,
56
+ "jsx-a11y/click-events-have-key-events": ["warn"],
57
+ "import/prefer-default-export": 0,
58
+ "import/extensions": ["warn", { jsx: "never" }],
59
+ "import/no-unresolved": 0,
60
+ "import/no-extraneous-dependencies": 0,
61
+ "react/jsx-curly-brace-presence": 0,
62
+ "react/jsx-filename-extension": ["warn", { extensions: [".js", ".jsx"] }],
63
+ "react/require-default-props": 0,
64
+ "react/jsx-curly-spacing": 0,
65
+ "react/jsx-indent": ["error", 4],
66
+ "react/jsx-indent-props": 0,
67
+ "react/forbid-prop-types": 0,
68
+ "react/sort-comp": 0,
69
+ "react/prop-types": 0,
70
+ "react/jsx-props-no-spreading": 0,
71
+ "react/jsx-no-bind": 0,
72
+ },
73
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const eslintConfig = require('./eslint/config');
2
+ const prettierConfig = require('./prettier/config');
3
+
4
+ module.exports = {
5
+ eslintConfig,
6
+ prettierConfig,
7
+ };
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ printWidth: 120,
3
+ tabWidth: 4,
4
+ singleQuote: true,
5
+ semi: true,
6
+ trailingComma: 'es5',
7
+ arrowParens: 'always',
8
+ };