frontend-guardian-core 2.6.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/LICENSE +21 -0
- package/bin/fg-core.js +1238 -0
- package/bin/watch-mode.js +123 -0
- package/dist/engine/cache.d.ts +68 -0
- package/dist/engine/cache.d.ts.map +1 -0
- package/dist/engine/cache.js +164 -0
- package/dist/engine/cache.js.map +1 -0
- package/dist/engine/rule-engine.d.ts +135 -0
- package/dist/engine/rule-engine.d.ts.map +1 -0
- package/dist/engine/rule-engine.js +716 -0
- package/dist/engine/rule-engine.js.map +1 -0
- package/dist/formatters/github-annotation.d.ts +36 -0
- package/dist/formatters/github-annotation.d.ts.map +1 -0
- package/dist/formatters/github-annotation.js +122 -0
- package/dist/formatters/github-annotation.js.map +1 -0
- package/dist/formatters/pr-comment.d.ts +43 -0
- package/dist/formatters/pr-comment.d.ts.map +1 -0
- package/dist/formatters/pr-comment.js +171 -0
- package/dist/formatters/pr-comment.js.map +1 -0
- package/dist/formatters/sarif.d.ts +104 -0
- package/dist/formatters/sarif.d.ts.map +1 -0
- package/dist/formatters/sarif.js +130 -0
- package/dist/formatters/sarif.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/base.d.ts +44 -0
- package/dist/integrations/base.d.ts.map +1 -0
- package/dist/integrations/base.js +104 -0
- package/dist/integrations/base.js.map +1 -0
- package/dist/integrations/eslint.d.ts +8 -0
- package/dist/integrations/eslint.d.ts.map +1 -0
- package/dist/integrations/eslint.js +67 -0
- package/dist/integrations/eslint.js.map +1 -0
- package/dist/integrations/formatter.d.ts +35 -0
- package/dist/integrations/formatter.d.ts.map +1 -0
- package/dist/integrations/formatter.js +182 -0
- package/dist/integrations/formatter.js.map +1 -0
- package/dist/integrations/index.d.ts +17 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +25 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/integrations/stylelint.d.ts +8 -0
- package/dist/integrations/stylelint.d.ts.map +1 -0
- package/dist/integrations/stylelint.js +59 -0
- package/dist/integrations/stylelint.js.map +1 -0
- package/dist/integrations/typescript.d.ts +8 -0
- package/dist/integrations/typescript.d.ts.map +1 -0
- package/dist/integrations/typescript.js +92 -0
- package/dist/integrations/typescript.js.map +1 -0
- package/dist/rules/registry.d.ts +83 -0
- package/dist/rules/registry.d.ts.map +1 -0
- package/dist/rules/registry.js +205 -0
- package/dist/rules/registry.js.map +1 -0
- package/dist/scanners/a11y-scanner.d.ts +14 -0
- package/dist/scanners/a11y-scanner.d.ts.map +1 -0
- package/dist/scanners/a11y-scanner.js +781 -0
- package/dist/scanners/a11y-scanner.js.map +1 -0
- package/dist/scanners/component-scanner.d.ts +12 -0
- package/dist/scanners/component-scanner.d.ts.map +1 -0
- package/dist/scanners/component-scanner.js +304 -0
- package/dist/scanners/component-scanner.js.map +1 -0
- package/dist/scanners/cross-file-scanner.d.ts +18 -0
- package/dist/scanners/cross-file-scanner.d.ts.map +1 -0
- package/dist/scanners/cross-file-scanner.js +684 -0
- package/dist/scanners/cross-file-scanner.js.map +1 -0
- package/dist/scanners/hooks-scanner.d.ts +15 -0
- package/dist/scanners/hooks-scanner.d.ts.map +1 -0
- package/dist/scanners/hooks-scanner.js +670 -0
- package/dist/scanners/hooks-scanner.js.map +1 -0
- package/dist/scanners/i18n-scanner.d.ts +13 -0
- package/dist/scanners/i18n-scanner.d.ts.map +1 -0
- package/dist/scanners/i18n-scanner.js +535 -0
- package/dist/scanners/i18n-scanner.js.map +1 -0
- package/dist/scanners/naming-scanner.d.ts +19 -0
- package/dist/scanners/naming-scanner.d.ts.map +1 -0
- package/dist/scanners/naming-scanner.js +746 -0
- package/dist/scanners/naming-scanner.js.map +1 -0
- package/dist/scanners/performance-scanner.d.ts +7 -0
- package/dist/scanners/performance-scanner.d.ts.map +1 -0
- package/dist/scanners/performance-scanner.js +402 -0
- package/dist/scanners/performance-scanner.js.map +1 -0
- package/dist/scanners/platform-scanner.d.ts +15 -0
- package/dist/scanners/platform-scanner.d.ts.map +1 -0
- package/dist/scanners/platform-scanner.js +320 -0
- package/dist/scanners/platform-scanner.js.map +1 -0
- package/dist/scanners/security-scanner.d.ts +7 -0
- package/dist/scanners/security-scanner.d.ts.map +1 -0
- package/dist/scanners/security-scanner.js +349 -0
- package/dist/scanners/security-scanner.js.map +1 -0
- package/dist/scanners/svelte-scanner.d.ts +14 -0
- package/dist/scanners/svelte-scanner.d.ts.map +1 -0
- package/dist/scanners/svelte-scanner.js +228 -0
- package/dist/scanners/svelte-scanner.js.map +1 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/ast-parser.d.ts +21 -0
- package/dist/utils/ast-parser.d.ts.map +1 -0
- package/dist/utils/ast-parser.js +119 -0
- package/dist/utils/ast-parser.js.map +1 -0
- package/dist/utils/baseline.d.ts +89 -0
- package/dist/utils/baseline.d.ts.map +1 -0
- package/dist/utils/baseline.js +156 -0
- package/dist/utils/baseline.js.map +1 -0
- package/dist/utils/ci-generator.d.ts +34 -0
- package/dist/utils/ci-generator.d.ts.map +1 -0
- package/dist/utils/ci-generator.js +194 -0
- package/dist/utils/ci-generator.js.map +1 -0
- package/dist/utils/common.d.ts +8 -0
- package/dist/utils/common.d.ts.map +1 -0
- package/dist/utils/common.js +38 -0
- package/dist/utils/common.js.map +1 -0
- package/dist/utils/concurrent.d.ts +16 -0
- package/dist/utils/concurrent.d.ts.map +1 -0
- package/dist/utils/concurrent.js +49 -0
- package/dist/utils/concurrent.js.map +1 -0
- package/dist/utils/config-loader.d.ts +8 -0
- package/dist/utils/config-loader.d.ts.map +1 -0
- package/dist/utils/config-loader.js +154 -0
- package/dist/utils/config-loader.js.map +1 -0
- package/dist/utils/fix-bot.d.ts +36 -0
- package/dist/utils/fix-bot.d.ts.map +1 -0
- package/dist/utils/fix-bot.js +274 -0
- package/dist/utils/fix-bot.js.map +1 -0
- package/dist/utils/git-hooks.d.ts +55 -0
- package/dist/utils/git-hooks.d.ts.map +1 -0
- package/dist/utils/git-hooks.js +318 -0
- package/dist/utils/git-hooks.js.map +1 -0
- package/dist/utils/history-report.d.ts +72 -0
- package/dist/utils/history-report.d.ts.map +1 -0
- package/dist/utils/history-report.js +144 -0
- package/dist/utils/history-report.js.map +1 -0
- package/dist/utils/init-config.d.ts +23 -0
- package/dist/utils/init-config.d.ts.map +1 -0
- package/dist/utils/init-config.js +146 -0
- package/dist/utils/init-config.js.map +1 -0
- package/dist/utils/pr-publisher.d.ts +64 -0
- package/dist/utils/pr-publisher.d.ts.map +1 -0
- package/dist/utils/pr-publisher.js +265 -0
- package/dist/utils/pr-publisher.js.map +1 -0
- package/dist/utils/project-detector.d.ts +20 -0
- package/dist/utils/project-detector.d.ts.map +1 -0
- package/dist/utils/project-detector.js +342 -0
- package/dist/utils/project-detector.js.map +1 -0
- package/dist/utils/report-uploader.d.ts +35 -0
- package/dist/utils/report-uploader.d.ts.map +1 -0
- package/dist/utils/report-uploader.js +106 -0
- package/dist/utils/report-uploader.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Hooks / Composables Scanner
|
|
4
|
+
* 迁移自 scan-hooks.sh,基于 AST 的 React Hooks / Vue Composables 检测
|
|
5
|
+
*
|
|
6
|
+
* 规则列表:
|
|
7
|
+
* 1. hooks-effect-deps — useEffect 依赖数组问题
|
|
8
|
+
* 2. hooks-closure — setInterval/setTimeout 未清理
|
|
9
|
+
* 3. hooks-custom-naming — 使用 hooks 但函数名不以 use 开头
|
|
10
|
+
* 4. composables-reactive — Vue reactive 解构陷阱
|
|
11
|
+
* 5. composables-computed — computed 副作用
|
|
12
|
+
* 6. hooks-state-lifting — 状态过多建议提升
|
|
13
|
+
*/
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.hooksRules = void 0;
|
|
19
|
+
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
20
|
+
const common_js_1 = require("../utils/common.js");
|
|
21
|
+
/** 常见的响应式变量名 */
|
|
22
|
+
const REACTIVE_NAMES = new Set([
|
|
23
|
+
"state",
|
|
24
|
+
"count",
|
|
25
|
+
"value",
|
|
26
|
+
"item",
|
|
27
|
+
"index",
|
|
28
|
+
"id",
|
|
29
|
+
"name",
|
|
30
|
+
"isOpen",
|
|
31
|
+
"isVisible",
|
|
32
|
+
"isLoading",
|
|
33
|
+
"error",
|
|
34
|
+
"result",
|
|
35
|
+
"response",
|
|
36
|
+
"data",
|
|
37
|
+
"list",
|
|
38
|
+
"form",
|
|
39
|
+
"query",
|
|
40
|
+
"params",
|
|
41
|
+
"user",
|
|
42
|
+
"current",
|
|
43
|
+
]);
|
|
44
|
+
exports.hooksRules = [
|
|
45
|
+
{
|
|
46
|
+
id: "hooks-effect-deps",
|
|
47
|
+
name: "useEffect 依赖数组问题",
|
|
48
|
+
description: "检测 useEffect 依赖过多、缺少依赖或空依赖陷阱",
|
|
49
|
+
severity: "warning",
|
|
50
|
+
category: "hooks",
|
|
51
|
+
defaultEnabled: true,
|
|
52
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-effect-deps.md",
|
|
53
|
+
frameworks: ["react", "nextjs"],
|
|
54
|
+
execute(context) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
const ast = context.utils.parseAST(context.source, {
|
|
57
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
58
|
+
});
|
|
59
|
+
if (!ast)
|
|
60
|
+
return issues;
|
|
61
|
+
(0, traverse_1.default)(ast, {
|
|
62
|
+
CallExpression(path) {
|
|
63
|
+
const callee = path.node.callee;
|
|
64
|
+
if (callee.type !== "Identifier" || callee.name !== "useEffect")
|
|
65
|
+
return;
|
|
66
|
+
const args = path.node.arguments;
|
|
67
|
+
const effectFn = args[0];
|
|
68
|
+
const depsArray = args[1];
|
|
69
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
70
|
+
// 1. 缺少依赖数组
|
|
71
|
+
if (!depsArray) {
|
|
72
|
+
issues.push({
|
|
73
|
+
ruleId: "hooks-effect-deps",
|
|
74
|
+
title: "useEffect 缺少依赖数组",
|
|
75
|
+
description: "useEffect 必须提供依赖数组 [] 或 [dep1, dep2],否则每次渲染都会执行",
|
|
76
|
+
severity: "warning",
|
|
77
|
+
file: context.filePath,
|
|
78
|
+
line,
|
|
79
|
+
column,
|
|
80
|
+
source: "useEffect(() => { ... })",
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// 2. 空依赖数组但引用了状态
|
|
85
|
+
let hasEmptyDepsIssue = false;
|
|
86
|
+
if (depsArray.type === "ArrayExpression" && depsArray.elements.length === 0) {
|
|
87
|
+
// 检查 effect 函数体是否引用了状态
|
|
88
|
+
if (effectFn?.type === "ArrowFunctionExpression" || effectFn?.type === "FunctionExpression") {
|
|
89
|
+
const body = effectFn.body;
|
|
90
|
+
if (body.type === "BlockStatement") {
|
|
91
|
+
const bodyText = context.source.slice(body.start || 0, body.end || 0);
|
|
92
|
+
if (/\bstate\b|\bprops\b|\buseState\b/.test(bodyText)) {
|
|
93
|
+
issues.push({
|
|
94
|
+
ruleId: "hooks-effect-deps",
|
|
95
|
+
title: "useEffect 空依赖数组但引用了状态",
|
|
96
|
+
description: "useEffect 使用 [] 但函数体内引用了 state/props,会导致闭包陷阱(获取到过期值)",
|
|
97
|
+
severity: "critical",
|
|
98
|
+
file: context.filePath,
|
|
99
|
+
line,
|
|
100
|
+
column,
|
|
101
|
+
source: "useEffect(() => { ... }, [])",
|
|
102
|
+
});
|
|
103
|
+
hasEmptyDepsIssue = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 只在检测到空依赖陷阱时才跳过后续检查
|
|
108
|
+
if (hasEmptyDepsIssue)
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// 3. 依赖过多(>5)
|
|
112
|
+
if (depsArray.type === "ArrayExpression" && depsArray.elements.length > 5) {
|
|
113
|
+
issues.push({
|
|
114
|
+
ruleId: "hooks-effect-deps",
|
|
115
|
+
title: `useEffect 依赖过多 (${depsArray.elements.length})`,
|
|
116
|
+
description: "useEffect 依赖过多(>5)说明逻辑过于复杂,建议拆分为多个 useEffect 或提取自定义 Hook",
|
|
117
|
+
severity: "warning",
|
|
118
|
+
file: context.filePath,
|
|
119
|
+
line,
|
|
120
|
+
column,
|
|
121
|
+
source: `useEffect(() => { ... }, [${depsArray.elements.length} deps])`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// 4. 检查常见变量是否在依赖中
|
|
125
|
+
if (effectFn?.type === "ArrowFunctionExpression" || effectFn?.type === "FunctionExpression") {
|
|
126
|
+
const body = effectFn.body;
|
|
127
|
+
if (body.type === "BlockStatement") {
|
|
128
|
+
const bodyText = context.source.slice(body.start || 0, body.end || 0);
|
|
129
|
+
for (const name of REACTIVE_NAMES) {
|
|
130
|
+
// 简单检测:变量在函数体中使用但不在依赖数组中
|
|
131
|
+
const regex = new RegExp(`\\b${name}\\b`);
|
|
132
|
+
if (regex.test(bodyText) && depsArray.type === "ArrayExpression") {
|
|
133
|
+
const inDeps = depsArray.elements.some((el) => el?.type === "Identifier" && el.name === name);
|
|
134
|
+
if (!inDeps) {
|
|
135
|
+
issues.push({
|
|
136
|
+
ruleId: "hooks-effect-deps",
|
|
137
|
+
title: `useEffect 可能缺少依赖: '${name}'`,
|
|
138
|
+
description: `变量 '${name}' 在 useEffect 中使用但不在依赖数组中,可能导致闭包陷阱`,
|
|
139
|
+
severity: "warning",
|
|
140
|
+
file: context.filePath,
|
|
141
|
+
line,
|
|
142
|
+
column,
|
|
143
|
+
source: `useEffect(() => { ...${name}... }, [...])`,
|
|
144
|
+
});
|
|
145
|
+
break; // 每 effect 只报一次
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return issues;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "hooks-closure",
|
|
158
|
+
name: "定时器未清理",
|
|
159
|
+
description: "setInterval / setTimeout 在 useEffect 中应清理",
|
|
160
|
+
severity: "critical",
|
|
161
|
+
category: "hooks",
|
|
162
|
+
defaultEnabled: true,
|
|
163
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-closure.md",
|
|
164
|
+
frameworks: ["react", "nextjs", "vue"],
|
|
165
|
+
execute(context) {
|
|
166
|
+
const issues = [];
|
|
167
|
+
const ast = context.utils.parseAST(context.source, {
|
|
168
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
169
|
+
});
|
|
170
|
+
if (!ast)
|
|
171
|
+
return issues;
|
|
172
|
+
(0, traverse_1.default)(ast, {
|
|
173
|
+
CallExpression(path) {
|
|
174
|
+
const callee = path.node.callee;
|
|
175
|
+
if (callee.type !== "Identifier")
|
|
176
|
+
return;
|
|
177
|
+
if (!["setInterval", "setTimeout"].includes(callee.name))
|
|
178
|
+
return;
|
|
179
|
+
// 检查是否在 useEffect 中
|
|
180
|
+
let parent = path.parentPath;
|
|
181
|
+
let inUseEffect = false;
|
|
182
|
+
while (parent) {
|
|
183
|
+
if (parent.isCallExpression()) {
|
|
184
|
+
const parentCallee = parent.node.callee;
|
|
185
|
+
if (parentCallee.type === "Identifier" && parentCallee.name === "useEffect") {
|
|
186
|
+
inUseEffect = true;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
parent = parent.parentPath;
|
|
191
|
+
}
|
|
192
|
+
if (!inUseEffect)
|
|
193
|
+
return;
|
|
194
|
+
// 检查是否有 cleanup(return () => clearInterval(...))
|
|
195
|
+
const useEffectPath = path.findParent((p) => p.isCallExpression() &&
|
|
196
|
+
p.node.callee?.type === "Identifier" &&
|
|
197
|
+
p.node.callee.name === "useEffect");
|
|
198
|
+
let hasCleanup = false;
|
|
199
|
+
if (useEffectPath) {
|
|
200
|
+
const callNode = useEffectPath.node;
|
|
201
|
+
const effectFn = callNode.arguments?.[0];
|
|
202
|
+
if (effectFn &&
|
|
203
|
+
(effectFn.type === "ArrowFunctionExpression" || effectFn.type === "FunctionExpression")) {
|
|
204
|
+
const body = effectFn.body;
|
|
205
|
+
if (body.type === "BlockStatement") {
|
|
206
|
+
for (const stmt of body.body) {
|
|
207
|
+
if (stmt.type === "ReturnStatement") {
|
|
208
|
+
const returnExpr = stmt.argument;
|
|
209
|
+
if (returnExpr) {
|
|
210
|
+
const returnText = context.source.slice(returnExpr.start || 0, returnExpr.end || 0);
|
|
211
|
+
if (/clearInterval|clearTimeout/.test(returnText)) {
|
|
212
|
+
hasCleanup = true;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!hasCleanup) {
|
|
222
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
223
|
+
issues.push({
|
|
224
|
+
ruleId: "hooks-closure",
|
|
225
|
+
title: `${callee.name} 缺少 cleanup`,
|
|
226
|
+
description: `useEffect 中的 ${callee.name} 必须返回 cleanup 函数(clearInterval/clearTimeout),否则会导致内存泄漏`,
|
|
227
|
+
severity: "critical",
|
|
228
|
+
file: context.filePath,
|
|
229
|
+
line,
|
|
230
|
+
column,
|
|
231
|
+
source: `${callee.name}(...)`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
return issues;
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "hooks-custom-naming",
|
|
241
|
+
name: "自定义 Hook 命名规范",
|
|
242
|
+
description: "使用 hooks 的函数应以 use 开头",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
category: "hooks",
|
|
245
|
+
defaultEnabled: true,
|
|
246
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-custom-naming.md",
|
|
247
|
+
frameworks: ["react", "nextjs"],
|
|
248
|
+
execute(context) {
|
|
249
|
+
const issues = [];
|
|
250
|
+
const ast = context.utils.parseAST(context.source, {
|
|
251
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
252
|
+
});
|
|
253
|
+
if (!ast)
|
|
254
|
+
return issues;
|
|
255
|
+
(0, traverse_1.default)(ast, {
|
|
256
|
+
FunctionDeclaration(path) {
|
|
257
|
+
const name = path.node.id?.name;
|
|
258
|
+
if (!name || name.startsWith("use"))
|
|
259
|
+
return;
|
|
260
|
+
// 检查函数体是否使用了 hooks
|
|
261
|
+
let usesHooks = false;
|
|
262
|
+
path.traverse({
|
|
263
|
+
CallExpression(innerPath) {
|
|
264
|
+
const callee = innerPath.node.callee;
|
|
265
|
+
if (callee.type === "Identifier" && /^use[A-Z]/.test(callee.name)) {
|
|
266
|
+
usesHooks = true;
|
|
267
|
+
innerPath.stop();
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
if (usesHooks) {
|
|
272
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
273
|
+
issues.push({
|
|
274
|
+
ruleId: "hooks-custom-naming",
|
|
275
|
+
title: `函数 '${name}' 应使用 use 前缀`,
|
|
276
|
+
description: `函数内部使用了 React Hooks,按照约定应以 'use' 开头命名(如 use${name.charAt(0).toUpperCase() + name.slice(1)})`,
|
|
277
|
+
severity: "warning",
|
|
278
|
+
file: context.filePath,
|
|
279
|
+
line,
|
|
280
|
+
column,
|
|
281
|
+
source: `function ${name}(...)`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
return issues;
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
id: "composables-reactive",
|
|
291
|
+
name: "Vue reactive 解构陷阱",
|
|
292
|
+
description: "reactive 对象被解构会丢失响应式",
|
|
293
|
+
severity: "critical",
|
|
294
|
+
category: "hooks",
|
|
295
|
+
defaultEnabled: true,
|
|
296
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/composables-reactive.md",
|
|
297
|
+
frameworks: ["vue", "nuxt"],
|
|
298
|
+
execute(context) {
|
|
299
|
+
const issues = [];
|
|
300
|
+
const ast = context.utils.parseAST(context.source, {
|
|
301
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
302
|
+
});
|
|
303
|
+
if (!ast)
|
|
304
|
+
return issues;
|
|
305
|
+
(0, traverse_1.default)(ast, {
|
|
306
|
+
VariableDeclarator(path) {
|
|
307
|
+
const init = path.node.init;
|
|
308
|
+
if (!init || init.type !== "CallExpression")
|
|
309
|
+
return;
|
|
310
|
+
const callee = init.callee;
|
|
311
|
+
if (callee.type !== "Identifier" || callee.name !== "reactive")
|
|
312
|
+
return;
|
|
313
|
+
// 检查是否是解构赋值
|
|
314
|
+
const id = path.node.id;
|
|
315
|
+
if (id.type === "ObjectPattern") {
|
|
316
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
317
|
+
issues.push({
|
|
318
|
+
ruleId: "composables-reactive",
|
|
319
|
+
title: "reactive 对象被解构",
|
|
320
|
+
description: "reactive 对象被解构后会丢失响应式,建议使用 toRefs() 或直接使用 state.xxx",
|
|
321
|
+
severity: "critical",
|
|
322
|
+
file: context.filePath,
|
|
323
|
+
line,
|
|
324
|
+
column,
|
|
325
|
+
source: "const { ... } = reactive(...)",
|
|
326
|
+
fix: {
|
|
327
|
+
text: "const { ... } = toRefs(reactive(...))",
|
|
328
|
+
start: { line, column },
|
|
329
|
+
end: { line, column: column + 5 },
|
|
330
|
+
confidence: "medium",
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
return issues;
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "composables-computed",
|
|
341
|
+
name: "computed 副作用",
|
|
342
|
+
description: "computed 中不应修改其他响应式数据",
|
|
343
|
+
severity: "warning",
|
|
344
|
+
category: "hooks",
|
|
345
|
+
defaultEnabled: true,
|
|
346
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/composables-computed.md",
|
|
347
|
+
frameworks: ["vue", "nuxt"],
|
|
348
|
+
execute(context) {
|
|
349
|
+
const issues = [];
|
|
350
|
+
const ast = context.utils.parseAST(context.source, {
|
|
351
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
352
|
+
});
|
|
353
|
+
if (!ast)
|
|
354
|
+
return issues;
|
|
355
|
+
(0, traverse_1.default)(ast, {
|
|
356
|
+
CallExpression(path) {
|
|
357
|
+
const callee = path.node.callee;
|
|
358
|
+
if (callee.type !== "Identifier" || callee.name !== "computed")
|
|
359
|
+
return;
|
|
360
|
+
const callback = path.node.arguments[0];
|
|
361
|
+
if (!callback ||
|
|
362
|
+
(callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression"))
|
|
363
|
+
return;
|
|
364
|
+
const body = callback.body;
|
|
365
|
+
if (body.type !== "BlockStatement")
|
|
366
|
+
return;
|
|
367
|
+
const bodyText = context.source.slice(body.start || 0, body.end || 0);
|
|
368
|
+
// 检测是否修改了 ref/reactive 值
|
|
369
|
+
if (/\bref\s*\(|\breactive\s*\(/.test(bodyText)) {
|
|
370
|
+
if (/=\s*[^=]|\+\+|--|\+=|-=|\*=|置/.test(bodyText)) {
|
|
371
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
372
|
+
issues.push({
|
|
373
|
+
ruleId: "composables-computed",
|
|
374
|
+
title: "computed 中存在副作用",
|
|
375
|
+
description: "computed 应为纯函数,不应修改其他响应式数据。副作用应放在 watch 或方法中",
|
|
376
|
+
severity: "warning",
|
|
377
|
+
file: context.filePath,
|
|
378
|
+
line,
|
|
379
|
+
column,
|
|
380
|
+
source: "computed(() => { ... })",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
return issues;
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "hooks-memo-deps",
|
|
391
|
+
name: "useMemo / useCallback 依赖数组问题",
|
|
392
|
+
description: "检测 useMemo 和 useCallback 缺少依赖数组或空依赖",
|
|
393
|
+
severity: "warning",
|
|
394
|
+
category: "hooks",
|
|
395
|
+
defaultEnabled: true,
|
|
396
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-memo-deps.md",
|
|
397
|
+
frameworks: ["react", "nextjs"],
|
|
398
|
+
execute(context) {
|
|
399
|
+
const issues = [];
|
|
400
|
+
const ast = context.utils.parseAST(context.source, {
|
|
401
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
402
|
+
});
|
|
403
|
+
if (!ast)
|
|
404
|
+
return issues;
|
|
405
|
+
const hookNames = ["useMemo", "useCallback"];
|
|
406
|
+
(0, traverse_1.default)(ast, {
|
|
407
|
+
CallExpression(path) {
|
|
408
|
+
const callee = path.node.callee;
|
|
409
|
+
if (callee.type !== "Identifier" || !hookNames.includes(callee.name))
|
|
410
|
+
return;
|
|
411
|
+
const args = path.node.arguments;
|
|
412
|
+
const depsArray = args[1];
|
|
413
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
414
|
+
if (!depsArray) {
|
|
415
|
+
issues.push({
|
|
416
|
+
ruleId: "hooks-memo-deps",
|
|
417
|
+
title: `${callee.name} 缺少依赖数组`,
|
|
418
|
+
description: `${callee.name} 必须提供依赖数组,否则每次渲染都会重新计算,失去缓存意义`,
|
|
419
|
+
severity: "warning",
|
|
420
|
+
file: context.filePath,
|
|
421
|
+
line,
|
|
422
|
+
column,
|
|
423
|
+
source: `${callee.name}(() => { ... })`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
else if (depsArray.type === "ArrayExpression" && depsArray.elements.length === 0) {
|
|
427
|
+
// 空依赖数组对于 useMemo/useCallback 通常是错误(除非是常量计算)
|
|
428
|
+
issues.push({
|
|
429
|
+
ruleId: "hooks-memo-deps",
|
|
430
|
+
title: `${callee.name} 使用了空依赖数组 []`,
|
|
431
|
+
description: `${callee.name} 使用 [] 表示只在挂载时计算一次。如果确实不需要依赖,直接使用常量即可,无需 ${callee.name}`,
|
|
432
|
+
severity: "suggestion",
|
|
433
|
+
file: context.filePath,
|
|
434
|
+
line,
|
|
435
|
+
column,
|
|
436
|
+
source: `${callee.name}(() => { ... }, [])`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
return issues;
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: "hooks-callback-misuse",
|
|
446
|
+
name: "useCallback 滥用",
|
|
447
|
+
description: "简单的回调函数不需要 useCallback 包裹",
|
|
448
|
+
severity: "suggestion",
|
|
449
|
+
category: "hooks",
|
|
450
|
+
defaultEnabled: true,
|
|
451
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-callback-misuse.md",
|
|
452
|
+
frameworks: ["react", "nextjs"],
|
|
453
|
+
execute(context) {
|
|
454
|
+
const issues = [];
|
|
455
|
+
const ast = context.utils.parseAST(context.source, {
|
|
456
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
457
|
+
});
|
|
458
|
+
if (!ast)
|
|
459
|
+
return issues;
|
|
460
|
+
(0, traverse_1.default)(ast, {
|
|
461
|
+
CallExpression(path) {
|
|
462
|
+
const callee = path.node.callee;
|
|
463
|
+
if (callee.type !== "Identifier" || callee.name !== "useCallback")
|
|
464
|
+
return;
|
|
465
|
+
const callbackFn = path.node.arguments[0];
|
|
466
|
+
if (!callbackFn ||
|
|
467
|
+
(callbackFn.type !== "ArrowFunctionExpression" && callbackFn.type !== "FunctionExpression"))
|
|
468
|
+
return;
|
|
469
|
+
const body = callbackFn.body;
|
|
470
|
+
let bodyText = "";
|
|
471
|
+
let isSimple = false;
|
|
472
|
+
// 情况 1: 表达式体 () => expr
|
|
473
|
+
if (body.type !== "BlockStatement") {
|
|
474
|
+
bodyText = context.source.slice(body.start || 0, body.end || 0);
|
|
475
|
+
isSimple = bodyText.length < 40;
|
|
476
|
+
}
|
|
477
|
+
else if (body.body.length === 1) {
|
|
478
|
+
// 情况 2: 块语句体 () => { expr } 或 () => { return expr }
|
|
479
|
+
const stmt = body.body[0];
|
|
480
|
+
if (stmt.type === "ExpressionStatement" || stmt.type === "ReturnStatement") {
|
|
481
|
+
bodyText = context.source.slice(body.start || 0, body.end || 0);
|
|
482
|
+
isSimple = bodyText.length < 40;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// 如果函数体很短且没有复杂逻辑,建议使用内联回调
|
|
486
|
+
if (isSimple && !/useState|useEffect|fetch|await/.test(bodyText)) {
|
|
487
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
488
|
+
issues.push({
|
|
489
|
+
ruleId: "hooks-callback-misuse",
|
|
490
|
+
title: "useCallback 包裹了简单回调",
|
|
491
|
+
description: "该回调函数逻辑非常简单,useCallback 的开销(依赖比较 + 缓存)可能大于收益。建议直接内联或使用内联回调。",
|
|
492
|
+
severity: "suggestion",
|
|
493
|
+
file: context.filePath,
|
|
494
|
+
line,
|
|
495
|
+
column,
|
|
496
|
+
source: "useCallback(() => { ... }, [...])",
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
return issues;
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
id: "hooks-missing-key",
|
|
506
|
+
name: "列表渲染缺少 key",
|
|
507
|
+
description: "map() 返回的 JSX 元素缺少 key 属性",
|
|
508
|
+
severity: "critical",
|
|
509
|
+
category: "hooks",
|
|
510
|
+
defaultEnabled: true,
|
|
511
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-missing-key.md",
|
|
512
|
+
frameworks: ["react", "nextjs"],
|
|
513
|
+
execute(context) {
|
|
514
|
+
const issues = [];
|
|
515
|
+
const ast = context.utils.parseAST(context.source, {
|
|
516
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
517
|
+
});
|
|
518
|
+
if (!ast)
|
|
519
|
+
return issues;
|
|
520
|
+
(0, traverse_1.default)(ast, {
|
|
521
|
+
CallExpression(path) {
|
|
522
|
+
const callee = path.node.callee;
|
|
523
|
+
if (callee.type !== "MemberExpression")
|
|
524
|
+
return;
|
|
525
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "map")
|
|
526
|
+
return;
|
|
527
|
+
const callback = path.node.arguments[0];
|
|
528
|
+
if (!callback ||
|
|
529
|
+
(callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression"))
|
|
530
|
+
return;
|
|
531
|
+
// 检查回调是否返回 JSX 元素
|
|
532
|
+
const returnBody = callback.body;
|
|
533
|
+
let returnsJSX = false;
|
|
534
|
+
if (returnBody.type === "JSXElement" || returnBody.type === "JSXFragment") {
|
|
535
|
+
returnsJSX = true;
|
|
536
|
+
}
|
|
537
|
+
else if (returnBody.type === "BlockStatement") {
|
|
538
|
+
for (const stmt of returnBody.body) {
|
|
539
|
+
if (stmt.type === "ReturnStatement" &&
|
|
540
|
+
stmt.argument &&
|
|
541
|
+
(stmt.argument.type === "JSXElement" || stmt.argument.type === "JSXFragment")) {
|
|
542
|
+
returnsJSX = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!returnsJSX)
|
|
548
|
+
return;
|
|
549
|
+
// 检查是否使用了 key 属性(简单检查:看参数解构或函数体中是否有 key 引用)
|
|
550
|
+
const bodyText = context.source.slice(returnBody.start || 0, returnBody.end || 0);
|
|
551
|
+
if (!/key\s*=/.test(bodyText)) {
|
|
552
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
553
|
+
issues.push({
|
|
554
|
+
ruleId: "hooks-missing-key",
|
|
555
|
+
title: "列表渲染缺少 key 属性",
|
|
556
|
+
description: "数组 map() 渲染 JSX 时必须提供唯一的 key 属性,否则 React 无法正确识别元素变化,导致性能问题和状态混乱",
|
|
557
|
+
severity: "critical",
|
|
558
|
+
file: context.filePath,
|
|
559
|
+
line,
|
|
560
|
+
column,
|
|
561
|
+
source: ".map((item) => <Component ... />)",
|
|
562
|
+
fix: {
|
|
563
|
+
text: ".map((item) => <Component key={item.id} ... />)",
|
|
564
|
+
start: { line, column },
|
|
565
|
+
end: { line, column: column + 4 },
|
|
566
|
+
confidence: "high",
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
return issues;
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
id: "hooks-conditional",
|
|
577
|
+
name: "条件调用 Hook",
|
|
578
|
+
description: "Hook 在条件语句或循环中调用,违反 Hook 规则",
|
|
579
|
+
severity: "critical",
|
|
580
|
+
category: "hooks",
|
|
581
|
+
defaultEnabled: true,
|
|
582
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-conditional.md",
|
|
583
|
+
frameworks: ["react", "nextjs"],
|
|
584
|
+
execute(context) {
|
|
585
|
+
const issues = [];
|
|
586
|
+
const ast = context.utils.parseAST(context.source, {
|
|
587
|
+
ext: (0, common_js_1.getFileExt)(context.filePath),
|
|
588
|
+
});
|
|
589
|
+
if (!ast)
|
|
590
|
+
return issues;
|
|
591
|
+
(0, traverse_1.default)(ast, {
|
|
592
|
+
CallExpression(path) {
|
|
593
|
+
const callee = path.node.callee;
|
|
594
|
+
if (callee.type !== "Identifier" || !/^use[A-Z]/.test(callee.name))
|
|
595
|
+
return;
|
|
596
|
+
// 检查是否在条件语句中
|
|
597
|
+
let parent = path.parentPath;
|
|
598
|
+
while (parent) {
|
|
599
|
+
if (parent.isIfStatement() ||
|
|
600
|
+
parent.isConditionalExpression() ||
|
|
601
|
+
parent.isSwitchCase() ||
|
|
602
|
+
parent.isLoop() ||
|
|
603
|
+
parent.isTryStatement()) {
|
|
604
|
+
const { line, column } = path.node.loc?.start || { line: 0, column: 0 };
|
|
605
|
+
issues.push({
|
|
606
|
+
ruleId: "hooks-conditional",
|
|
607
|
+
title: `${callee.name} 在条件/循环中调用`,
|
|
608
|
+
description: `React Hooks 必须在组件顶层无条件调用。将 ${callee.name} 移到条件语句之外,或确保它在每次渲染时都按相同顺序执行。`,
|
|
609
|
+
severity: "critical",
|
|
610
|
+
file: context.filePath,
|
|
611
|
+
line,
|
|
612
|
+
column,
|
|
613
|
+
source: `${callee.name}(...)`,
|
|
614
|
+
});
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
parent = parent.parentPath;
|
|
618
|
+
if (!parent)
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
return issues;
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
id: "hooks-state-lifting",
|
|
628
|
+
name: "状态提升建议",
|
|
629
|
+
description: "组件中状态过多建议合并或提升到父组件",
|
|
630
|
+
severity: "suggestion",
|
|
631
|
+
category: "hooks",
|
|
632
|
+
defaultEnabled: true,
|
|
633
|
+
docsUrl: "https://github.com/wzm111/frontend-guardian/blob/main/docs/rules/hooks-state-lifting.md",
|
|
634
|
+
frameworks: ["react", "nextjs", "vue", "nuxt"],
|
|
635
|
+
execute(context) {
|
|
636
|
+
const issues = [];
|
|
637
|
+
const source = context.source;
|
|
638
|
+
// React: 统计 useState 调用次数
|
|
639
|
+
const useStateMatches = source.match(/\buseState\s*\(/g);
|
|
640
|
+
if (useStateMatches && useStateMatches.length > 5) {
|
|
641
|
+
issues.push({
|
|
642
|
+
ruleId: "hooks-state-lifting",
|
|
643
|
+
title: `组件使用了 ${useStateMatches.length} 个 useState`,
|
|
644
|
+
description: "组件使用了过多 useState,建议将相关状态合并为对象或使用 useReducer,或考虑状态提升到父组件",
|
|
645
|
+
severity: "suggestion",
|
|
646
|
+
file: context.filePath,
|
|
647
|
+
line: 1,
|
|
648
|
+
column: 1,
|
|
649
|
+
source: `${useStateMatches.length} x useState(...)`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
// Vue: 统计 ref 调用次数
|
|
653
|
+
const refMatches = source.match(/\bref\s*\(/g);
|
|
654
|
+
if (refMatches && refMatches.length > 8) {
|
|
655
|
+
issues.push({
|
|
656
|
+
ruleId: "hooks-state-lifting",
|
|
657
|
+
title: `组件使用了 ${refMatches.length} 个 ref`,
|
|
658
|
+
description: "组件使用了过多 ref,建议使用 reactive 合并相关状态,或考虑状态提升到父组件",
|
|
659
|
+
severity: "suggestion",
|
|
660
|
+
file: context.filePath,
|
|
661
|
+
line: 1,
|
|
662
|
+
column: 1,
|
|
663
|
+
source: `${refMatches.length} x ref(...)`,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return issues;
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
];
|
|
670
|
+
//# sourceMappingURL=hooks-scanner.js.map
|