devassist-agent 1.0.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/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- package/tests/test-new-rules.js +157 -0
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeQualityGuard - static code analysis rules.
|
|
3
|
+
*
|
|
4
|
+
* Defense target: "Code defects" (33% of 88 failures).
|
|
5
|
+
* #4 - race condition in bindAppEvents (timing dependency)
|
|
6
|
+
* #5 - getElementById vs querySelector confusion
|
|
7
|
+
* #6 - no defensive programming (3 null checks missing → app crash)
|
|
8
|
+
*
|
|
9
|
+
* This module uses regex + simple AST-like analysis (no external deps)
|
|
10
|
+
* to catch the most common code quality issues.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { Logger } = require('../../core/logger');
|
|
15
|
+
const log = new Logger('CodeQualityGuard');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} Rule
|
|
19
|
+
* @property {string} id
|
|
20
|
+
* @property {string} severity - block | warn | info
|
|
21
|
+
* @property {string} category
|
|
22
|
+
* @property {function} check - (ctx) => Finding | Finding[] | null
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// --- Individual rules ---
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rule: Detect getElementById used with class selectors (failure #5)
|
|
29
|
+
* The report showed code like: document.getElementById('some-class-name')
|
|
30
|
+
* when the element actually had a class, not an id.
|
|
31
|
+
*/
|
|
32
|
+
const rule_id_class_confusion = {
|
|
33
|
+
id: 'cq-id-class-confusion',
|
|
34
|
+
severity: 'warn',
|
|
35
|
+
category: 'code-quality',
|
|
36
|
+
check(ctx) {
|
|
37
|
+
if (!ctx.files) return null;
|
|
38
|
+
const findings = [];
|
|
39
|
+
for (const file of ctx.files) {
|
|
40
|
+
if (!file.content || !/\.(js|ts|jsx|tsx|html)$/.test(file.path)) continue;
|
|
41
|
+
// Pattern: getElementById('.something') — using a dot (class selector) in getElementById
|
|
42
|
+
const regex = /getElementById\s*\(\s*['"`]\s*\./g;
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = regex.exec(file.content)) !== null) {
|
|
45
|
+
const line = file.content.substring(0, match.index).split('\n').length;
|
|
46
|
+
findings.push({
|
|
47
|
+
severity: 'warn',
|
|
48
|
+
message: `getElementById 使用了类选择器(应使用 querySelector 或真实 ID)`,
|
|
49
|
+
file: file.path,
|
|
50
|
+
line,
|
|
51
|
+
suggestion: `改用 document.querySelector('.classname'),或确认元素确实有 id 属性`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings.length > 0 ? findings : null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rule: Detect missing null checks before property access (failure #6)
|
|
61
|
+
* Pattern: obj.property.method() without checking obj exists first.
|
|
62
|
+
*/
|
|
63
|
+
const rule_null_safety = {
|
|
64
|
+
id: 'cq-null-safety',
|
|
65
|
+
severity: 'warn',
|
|
66
|
+
category: 'code-quality',
|
|
67
|
+
check(ctx) {
|
|
68
|
+
if (!ctx.files) return null;
|
|
69
|
+
const findings = [];
|
|
70
|
+
for (const file of ctx.files) {
|
|
71
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
72
|
+
const content = file.content;
|
|
73
|
+
|
|
74
|
+
// Detect chains like: data.response.result.someMethod()
|
|
75
|
+
// without prior null check — common in the 88-failure report
|
|
76
|
+
const lines = content.split('\n');
|
|
77
|
+
lines.forEach((line, i) => {
|
|
78
|
+
// Skip lines that already have optional chaining or null checks
|
|
79
|
+
if (/\?\./.test(line) || /if\s*\(.*null/.test(line) || /if\s*\(.*undefined/.test(line)) return;
|
|
80
|
+
// Detect deep property access chains (3+ levels) without null safety
|
|
81
|
+
const chainMatch = line.match(/(\w+\.){3,}\w+\s*\(/);
|
|
82
|
+
if (chainMatch) {
|
|
83
|
+
// Check if there's a null check within 3 lines above
|
|
84
|
+
const context = lines.slice(Math.max(0, i - 3), i).join('\n');
|
|
85
|
+
if (!/null|undefined|if\s*\(/.test(context)) {
|
|
86
|
+
findings.push({
|
|
87
|
+
severity: 'warn',
|
|
88
|
+
message: `深层属性链未做空值检查:${chainMatch[0].trim()}`,
|
|
89
|
+
file: file.path,
|
|
90
|
+
line: i + 1,
|
|
91
|
+
suggestion: `使用可选链操作符(?.)或在访问嵌套属性前显式判空`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return findings.length > 0 ? findings : null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rule: Detect missing try/catch around async operations (failure #6)
|
|
103
|
+
*/
|
|
104
|
+
const rule_missing_trycatch = {
|
|
105
|
+
id: 'cq-missing-trycatch',
|
|
106
|
+
severity: 'warn',
|
|
107
|
+
category: 'code-quality',
|
|
108
|
+
check(ctx) {
|
|
109
|
+
if (!ctx.files) return null;
|
|
110
|
+
const findings = [];
|
|
111
|
+
for (const file of ctx.files) {
|
|
112
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
113
|
+
const lines = file.content.split('\n');
|
|
114
|
+
lines.forEach((line, i) => {
|
|
115
|
+
// Detect await without surrounding try/catch
|
|
116
|
+
if (/\bawait\b/.test(line)) {
|
|
117
|
+
const context = lines.slice(Math.max(0, i - 5), i + 1).join('\n');
|
|
118
|
+
if (!/try\s*\{/.test(context) && !/\.catch\s*\(/.test(line) && !/\.then\s*\(/.test(line)) {
|
|
119
|
+
findings.push({
|
|
120
|
+
severity: 'info',
|
|
121
|
+
message: `await 未被 try/catch 或 .catch() 包裹`,
|
|
122
|
+
file: file.path,
|
|
123
|
+
line: i + 1,
|
|
124
|
+
suggestion: `用 try/catch 包裹 await,或添加 .catch() 处理可能的 Promise 拒绝`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return findings.length > 0 ? findings : null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Rule: Detect potential race conditions in event binding (failure #4)
|
|
136
|
+
* Pattern: bindAppEvents called before DOM is ready
|
|
137
|
+
*/
|
|
138
|
+
const race_condition = {
|
|
139
|
+
id: 'cq-race-condition',
|
|
140
|
+
severity: 'block',
|
|
141
|
+
category: 'code-quality',
|
|
142
|
+
check(ctx) {
|
|
143
|
+
if (!ctx.files) return null;
|
|
144
|
+
const findings = [];
|
|
145
|
+
for (const file of ctx.files) {
|
|
146
|
+
if (!file.content || !/\.(js|ts|jsx|tsx|html)$/.test(file.path)) continue;
|
|
147
|
+
const content = file.content;
|
|
148
|
+
|
|
149
|
+
// Pattern: addEventListener or event binding before DOMContentLoaded
|
|
150
|
+
const hasEarlyBinding = /addEventListener|\.on\s*\(/.test(content.split('DOMContentLoaded')[0] || content);
|
|
151
|
+
const hasDOMContentLoaded = /DOMContentLoaded/.test(content);
|
|
152
|
+
const isModule = /document\./.test(content);
|
|
153
|
+
|
|
154
|
+
if (hasEarlyBinding && hasDOMContentLoaded && isModule) {
|
|
155
|
+
const lines = content.split('\n');
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
if (/addEventListener|\.on\s*\(/.test(lines[i])) {
|
|
158
|
+
const beforeContent = lines.slice(0, i).join('\n');
|
|
159
|
+
if (!/DOMContentLoaded|document\.ready|\$\(document\)\.ready/.test(beforeContent)) {
|
|
160
|
+
findings.push({
|
|
161
|
+
severity: 'block',
|
|
162
|
+
message: `在 DOMContentLoaded 之前绑定事件——存在竞态条件风险`,
|
|
163
|
+
file: file.path,
|
|
164
|
+
line: i + 1,
|
|
165
|
+
suggestion: `将事件绑定移到 DOMContentLoaded 回调中,或确保 DOM 已就绪后再绑定`,
|
|
166
|
+
});
|
|
167
|
+
break; // one per file
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return findings.length > 0 ? findings : null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rule: Detect duplicate variable declarations (failure #8)
|
|
179
|
+
* Only flags `var` redeclarations (same function scope) — let/const in
|
|
180
|
+
* different blocks are legal and not a bug. Also skips common generic names
|
|
181
|
+
* that legitimately appear in different functions.
|
|
182
|
+
*/
|
|
183
|
+
const COMMON_NAMES = new Set(['i', 'j', 'k', 'n', 'x', 'y', 'err', 'error', 'e',
|
|
184
|
+
'file', 'content', 'data', 'result', 'findings', 'lines', 'match', 'line',
|
|
185
|
+
'entry', 'entries', 'dir', 'full', 'path', 'name', 'value', 'key', 'item',
|
|
186
|
+
'fn', 'cb', 'req', 'res', 'ctx', 'config', 'options', 'obj', 'arr',
|
|
187
|
+
'manifest', 'backupPath', 'rel', 'node', 'findings', 'stats', 'conv',
|
|
188
|
+
'report', 'list', 'handler', 'destDir', 'fullDir', 'fullPath', 'content',
|
|
189
|
+
'systemPrompt', 'userPrompt', 'provider', 'body', 'response', 'portMatch',
|
|
190
|
+
'existing', 'variants', 'duplicates', 'declarations', 'exportRegex',
|
|
191
|
+
'fileEntry', 'manifestPath', 'dirs', 'files',
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const duplicate_declaration = {
|
|
195
|
+
id: 'cq-duplicate-declaration',
|
|
196
|
+
severity: 'block',
|
|
197
|
+
category: 'code-quality',
|
|
198
|
+
check(ctx) {
|
|
199
|
+
if (!ctx.files) return null;
|
|
200
|
+
const findings = [];
|
|
201
|
+
for (const file of ctx.files) {
|
|
202
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
203
|
+
const content = file.content;
|
|
204
|
+
|
|
205
|
+
// Only check `var` redeclarations — these share function scope and are real bugs
|
|
206
|
+
// let/const in different blocks are perfectly valid
|
|
207
|
+
const varRegex = /\bvar\s+(\w+)/g;
|
|
208
|
+
const varDecls = new Map();
|
|
209
|
+
let match;
|
|
210
|
+
while ((match = varRegex.exec(content)) !== null) {
|
|
211
|
+
const name = match[1];
|
|
212
|
+
if (COMMON_NAMES.has(name)) continue;
|
|
213
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
214
|
+
if (varDecls.has(name)) {
|
|
215
|
+
const prev = varDecls.get(name);
|
|
216
|
+
findings.push({
|
|
217
|
+
severity: 'block',
|
|
218
|
+
message: `重复的 'var' 声明:'${name}'(在第 ${prev} 行也已声明)`,
|
|
219
|
+
file: file.path,
|
|
220
|
+
line,
|
|
221
|
+
suggestion: `重命名变量或改用 let/const 进行块级作用域声明`,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
varDecls.set(name, line);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Also check for function redeclarations (same name, two function statements)
|
|
229
|
+
const funcRegex = /\bfunction\s+(\w+)\s*\(/g;
|
|
230
|
+
const funcDecls = new Map();
|
|
231
|
+
while ((match = funcRegex.exec(content)) !== null) {
|
|
232
|
+
const name = match[1];
|
|
233
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
234
|
+
if (funcDecls.has(name)) {
|
|
235
|
+
const prev = funcDecls.get(name);
|
|
236
|
+
findings.push({
|
|
237
|
+
severity: 'block',
|
|
238
|
+
message: `重复的函数声明:'${name}'(在第 ${prev} 行也已声明)`,
|
|
239
|
+
file: file.path,
|
|
240
|
+
line,
|
|
241
|
+
suggestion: `重命名其中一个函数——重复的函数声明会静默覆盖彼此`,
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
funcDecls.set(name, line);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return findings.length > 0 ? findings : null;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Rule: File too large — module boundary guard (failure #9, over-engineering)
|
|
254
|
+
*/
|
|
255
|
+
const file_too_large = {
|
|
256
|
+
id: 'cq-file-too-large',
|
|
257
|
+
severity: 'warn',
|
|
258
|
+
category: 'code-quality',
|
|
259
|
+
check(ctx) {
|
|
260
|
+
if (!ctx.files) return null;
|
|
261
|
+
const findings = [];
|
|
262
|
+
const maxLines = (ctx.config && ctx.config.maxFileLines) || 800;
|
|
263
|
+
for (const file of ctx.files) {
|
|
264
|
+
if (!file.content) continue;
|
|
265
|
+
const lines = file.content.split('\n').length;
|
|
266
|
+
if (lines > maxLines) {
|
|
267
|
+
findings.push({
|
|
268
|
+
severity: 'warn',
|
|
269
|
+
message: `文件共 ${lines} 行(上限 ${maxLines} 行)——建议拆分`,
|
|
270
|
+
file: file.path,
|
|
271
|
+
line: 1,
|
|
272
|
+
suggestion: `拆分为更小的模块。88条故障报告显示 app.js 膨胀至 2720 行后,AI 误覆盖为 106 行。`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return findings.length > 0 ? findings : null;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ========== Phase 3 新增规则(基于88条故障报告扩展)==========
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Rule: API 响应命名不一致检测(故障 #1 根因:data.list vs data.items vs data.records)
|
|
284
|
+
* 88条故障中32条与"AI遗忘早期约定"有关,最典型的就是列表键名漂移。
|
|
285
|
+
* 当同一项目中出现多种列表键名时,标记为 BLOCK。
|
|
286
|
+
*/
|
|
287
|
+
const api_response_naming = {
|
|
288
|
+
id: 'cq-api-naming-drift',
|
|
289
|
+
severity: 'block',
|
|
290
|
+
category: 'convention',
|
|
291
|
+
check(ctx) {
|
|
292
|
+
if (!ctx.files) return null;
|
|
293
|
+
const findings = [];
|
|
294
|
+
const listKeyVariants = new Map(); // key -> [{file, line}]
|
|
295
|
+
|
|
296
|
+
for (const file of ctx.files) {
|
|
297
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
298
|
+
const content = file.content;
|
|
299
|
+
|
|
300
|
+
// Detect common list key patterns: data.items, data.records, data.list, data.rows, data.results
|
|
301
|
+
// Match both assignment (data.items =) and access (data.items.map, data.items[)
|
|
302
|
+
const patterns = [
|
|
303
|
+
{ regex: /\.(items)\s*[=:[.]/g, key: 'items' },
|
|
304
|
+
{ regex: /\.(records)\s*[=:[.]/g, key: 'records' },
|
|
305
|
+
{ regex: /\.(list)\s*[=:[.]/g, key: 'list' },
|
|
306
|
+
{ regex: /\.(rows)\s*[=:[.]/g, key: 'rows' },
|
|
307
|
+
{ regex: /\.(results)\s*[=:[.]/g, key: 'results' },
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
for (const { regex, key } of patterns) {
|
|
311
|
+
let match;
|
|
312
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
313
|
+
while ((match = re.exec(content)) !== null) {
|
|
314
|
+
// Only match data.xxx or response.xxx patterns (skip variable declarations)
|
|
315
|
+
const beforeMatch = content.substring(Math.max(0, match.index - 20), match.index);
|
|
316
|
+
if (/data|response|res|result/i.test(beforeMatch)) {
|
|
317
|
+
if (!listKeyVariants.has(key)) listKeyVariants.set(key, []);
|
|
318
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
319
|
+
listKeyVariants.get(key).push({ file: file.path, line });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// If 2+ different key variants are used, flag all
|
|
326
|
+
if (listKeyVariants.size >= 2) {
|
|
327
|
+
const variants = [...listKeyVariants.keys()].join(' / ');
|
|
328
|
+
for (const [key, locations] of listKeyVariants) {
|
|
329
|
+
for (const loc of locations.slice(0, 3)) { // limit to 3 per key
|
|
330
|
+
findings.push({
|
|
331
|
+
severity: 'block',
|
|
332
|
+
message: `API 列表键名不一致:项目中同时使用了 ${variants}(当前:${key})`,
|
|
333
|
+
file: loc.file,
|
|
334
|
+
line: loc.line,
|
|
335
|
+
suggestion: `统一使用一种键名(推荐 data.list)。运行 devassist convention add --id naming-list-key --rule "使用 data.list" --severity block 来强制约定`,
|
|
336
|
+
context: { variants: [...listKeyVariants.keys()], currentKey: key },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return findings.length > 0 ? findings : null;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Rule: fetch/XHR 缺少错误处理(故障 #6:防御性编程缺失)
|
|
347
|
+
* 未处理的 fetch 调用会导致网络错误时应用崩溃。
|
|
348
|
+
*/
|
|
349
|
+
const fetch_no_catch = {
|
|
350
|
+
id: 'cq-fetch-no-catch',
|
|
351
|
+
severity: 'warn',
|
|
352
|
+
category: 'code-quality',
|
|
353
|
+
check(ctx) {
|
|
354
|
+
if (!ctx.files) return null;
|
|
355
|
+
const findings = [];
|
|
356
|
+
for (const file of ctx.files) {
|
|
357
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
358
|
+
const lines = file.content.split('\n');
|
|
359
|
+
lines.forEach((line, i) => {
|
|
360
|
+
// Detect fetch() call
|
|
361
|
+
if (/\bfetch\s*\(/.test(line)) {
|
|
362
|
+
// Check if there's .catch() on the same line or next few lines
|
|
363
|
+
const context = lines.slice(i, Math.min(i + 5, lines.length)).join('\n');
|
|
364
|
+
const hasCatch = /\.catch\s*\(/.test(context);
|
|
365
|
+
const hasAwait = /\bawait\b/.test(line);
|
|
366
|
+
const hasTryCatch = /try\s*\{/.test(lines.slice(Math.max(0, i - 5), i + 1).join('\n'));
|
|
367
|
+
|
|
368
|
+
if (!hasCatch && !(hasAwait && hasTryCatch)) {
|
|
369
|
+
findings.push({
|
|
370
|
+
severity: 'warn',
|
|
371
|
+
message: `fetch 调用缺少错误处理——网络错误时可能导致未捕获的 Promise 拒绝`,
|
|
372
|
+
file: file.path,
|
|
373
|
+
line: i + 1,
|
|
374
|
+
suggestion: `添加 .catch() 处理器,或用 try/catch 包裹 await fetch()`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return findings.length > 0 ? findings : null;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Rule: 硬编码配置值检测(故障 #1, #3, #13:端口/路径/URL硬编码导致部署问题)
|
|
386
|
+
* 端口号、API URL、文件路径不应硬编码在代码中。
|
|
387
|
+
*/
|
|
388
|
+
const hardcoded_config = {
|
|
389
|
+
id: 'cq-hardcoded-config',
|
|
390
|
+
severity: 'warn',
|
|
391
|
+
category: 'code-quality',
|
|
392
|
+
check(ctx) {
|
|
393
|
+
if (!ctx.files) return null;
|
|
394
|
+
const findings = [];
|
|
395
|
+
for (const file of ctx.files) {
|
|
396
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
397
|
+
// Skip config files themselves
|
|
398
|
+
if (/config|\.config\.|env/i.test(file.path)) continue;
|
|
399
|
+
|
|
400
|
+
const lines = file.content.split('\n');
|
|
401
|
+
lines.forEach((line, i) => {
|
|
402
|
+
// Skip comments
|
|
403
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
404
|
+
|
|
405
|
+
// Detect hardcoded port: :8080, :3000, :5000 etc (but not in comments or strings like "8080px")
|
|
406
|
+
const portMatch = line.match(/['":\s](\d{4,5})\b(?!\s*(px|em|rem|%|ms|s))/);
|
|
407
|
+
if (portMatch && /^(80|443|3000|5000|8000|8080|8443|8888|9090)$/.test(portMatch[1])) {
|
|
408
|
+
// Skip if it's clearly a variable assignment to a config object
|
|
409
|
+
if (!/process\.env|config|PORT/.test(line)) {
|
|
410
|
+
findings.push({
|
|
411
|
+
severity: 'warn',
|
|
412
|
+
message: `疑似硬编码端口:${portMatch[1]}(应使用环境变量或配置文件)`,
|
|
413
|
+
file: file.path,
|
|
414
|
+
line: i + 1,
|
|
415
|
+
suggestion: `使用 process.env.PORT 或配置文件管理端口号。故障 #1 中 supervisord 占用 8080 导致应用无法启动。`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Detect hardcoded http URL (not localhost in dev)
|
|
421
|
+
const urlMatch = line.match(/['"]https?:\/\/(?!localhost|127\.0\.0\.1)[^'"]+/);
|
|
422
|
+
if (urlMatch && !/import|require|sourceMappingURL/.test(line)) {
|
|
423
|
+
findings.push({
|
|
424
|
+
severity: 'info',
|
|
425
|
+
message: `疑似硬编码 URL:${urlMatch[0].slice(0, 50)}...(考虑使用配置管理)`,
|
|
426
|
+
file: file.path,
|
|
427
|
+
line: i + 1,
|
|
428
|
+
suggestion: `将 API URL 移到配置文件或环境变量中,避免环境切换时遗漏`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return findings.length > 0 ? findings : null;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Rule: console.log 残留检测(故障 #8:调试代码残留导致信息泄漏)
|
|
439
|
+
* 生产代码中不应保留 console.log 调试输出。
|
|
440
|
+
*/
|
|
441
|
+
const console_log_residual = {
|
|
442
|
+
id: 'cq-console-log',
|
|
443
|
+
severity: 'info',
|
|
444
|
+
category: 'code-quality',
|
|
445
|
+
check(ctx) {
|
|
446
|
+
if (!ctx.files) return null;
|
|
447
|
+
const findings = [];
|
|
448
|
+
for (const file of ctx.files) {
|
|
449
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
450
|
+
// Skip logger files and test files
|
|
451
|
+
if (/logger|test|spec|__test/.test(file.path)) continue;
|
|
452
|
+
|
|
453
|
+
const lines = file.content.split('\n');
|
|
454
|
+
let logCount = 0;
|
|
455
|
+
lines.forEach((line, i) => {
|
|
456
|
+
// Skip comments
|
|
457
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
458
|
+
// Detect console.log (but not console.error/warn/info which are intentional)
|
|
459
|
+
if (/console\.log\s*\(/.test(line)) {
|
|
460
|
+
logCount++;
|
|
461
|
+
if (logCount <= 5) { // limit findings per file
|
|
462
|
+
findings.push({
|
|
463
|
+
severity: 'info',
|
|
464
|
+
message: `console.log 残留——生产代码应使用正式日志模块`,
|
|
465
|
+
file: file.path,
|
|
466
|
+
line: i + 1,
|
|
467
|
+
suggestion: `移除调试日志,或替换为 Logger 模块的 log.debug() 调用`,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
if (logCount > 5) {
|
|
473
|
+
findings.push({
|
|
474
|
+
severity: 'warn',
|
|
475
|
+
message: `文件中有 ${logCount} 处 console.log(过多调试日志)`,
|
|
476
|
+
file: file.path,
|
|
477
|
+
line: 1,
|
|
478
|
+
suggestion: `批量清理 console.log,改用统一日志模块。过多的调试输出会影响性能和安全性。`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return findings.length > 0 ? findings : null;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Rule: async 函数调用缺少 await(故障 #6:异步时序问题)
|
|
488
|
+
* 调用 async 函数但不 await 会导致 Promise 被丢弃,错误被吞没。
|
|
489
|
+
*/
|
|
490
|
+
const missing_await = {
|
|
491
|
+
id: 'cq-missing-await',
|
|
492
|
+
severity: 'warn',
|
|
493
|
+
category: 'code-quality',
|
|
494
|
+
check(ctx) {
|
|
495
|
+
if (!ctx.files) return null;
|
|
496
|
+
const findings = [];
|
|
497
|
+
for (const file of ctx.files) {
|
|
498
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
499
|
+
const lines = file.content.split('\n');
|
|
500
|
+
|
|
501
|
+
lines.forEach((line, i) => {
|
|
502
|
+
// Skip comments
|
|
503
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
504
|
+
// Skip lines that already have await
|
|
505
|
+
if (/\bawait\b/.test(line)) return;
|
|
506
|
+
|
|
507
|
+
// Detect common async function calls without await:
|
|
508
|
+
// - function names starting with get/fetch/load/init/save/update/delete + camelCase
|
|
509
|
+
const asyncCallMatch = line.match(/\b(get|fetch|load|init|save|update|delete|create|remove|send|post|put)\w+\s*\(/);
|
|
510
|
+
if (asyncCallMatch) {
|
|
511
|
+
// Check if it's a standalone call (not assigned to a variable, not in a condition)
|
|
512
|
+
const trimmed = line.trim();
|
|
513
|
+
// Skip if it's part of an assignment or condition (those usually handle the promise)
|
|
514
|
+
if (/^\s*(const|let|var|if|while|return|throw)/.test(trimmed)) return;
|
|
515
|
+
// Skip if followed by .then or .catch
|
|
516
|
+
if (/\.then\s*\(|\.catch\s*\(|\.finally\s*\(/.test(line)) return;
|
|
517
|
+
|
|
518
|
+
findings.push({
|
|
519
|
+
severity: 'warn',
|
|
520
|
+
message: `疑似 async 函数调用缺少 await:${asyncCallMatch[0].replace(/\($/, '')}`,
|
|
521
|
+
file: file.path,
|
|
522
|
+
line: i + 1,
|
|
523
|
+
suggestion: `如果该函数返回 Promise,请添加 await。未 await 的 Promise 会被丢弃,错误不会传播。`,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return findings.length > 0 ? findings : null;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Rule: 事件监听无清理检测(故障 #4:竞态条件和内存泄漏)
|
|
534
|
+
* addEventListener 必须有对应的 removeEventListener,否则内存泄漏。
|
|
535
|
+
*/
|
|
536
|
+
const event_listener_leak = {
|
|
537
|
+
id: 'cq-event-listener-leak',
|
|
538
|
+
severity: 'warn',
|
|
539
|
+
category: 'code-quality',
|
|
540
|
+
check(ctx) {
|
|
541
|
+
if (!ctx.files) return null;
|
|
542
|
+
const findings = [];
|
|
543
|
+
for (const file of ctx.files) {
|
|
544
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
545
|
+
const content = file.content;
|
|
546
|
+
|
|
547
|
+
const addCount = (content.match(/addEventListener\s*\(/g) || []).length;
|
|
548
|
+
const removeCount = (content.match(/removeEventListener\s*\(/g) || []).length;
|
|
549
|
+
|
|
550
|
+
// If there are addEventListener calls but no removeEventListener
|
|
551
|
+
if (addCount > 0 && removeCount === 0) {
|
|
552
|
+
// Check if it's a short-lived page (unlikely to need cleanup)
|
|
553
|
+
const lineCount = content.split('\n').length;
|
|
554
|
+
if (lineCount > 100) {
|
|
555
|
+
// Find first addEventListener
|
|
556
|
+
const match = content.match(/addEventListener\s*\(/);
|
|
557
|
+
if (match) {
|
|
558
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
559
|
+
findings.push({
|
|
560
|
+
severity: 'warn',
|
|
561
|
+
message: `有 ${addCount} 处 addEventListener 但 0 处 removeEventListener——可能导致内存泄漏`,
|
|
562
|
+
file: file.path,
|
|
563
|
+
line,
|
|
564
|
+
suggestion: `在组件销毁/页面卸载时调用 removeEventListener 清理事件监听器。使用 AbortController 或在 destroy/cleanup 方法中统一移除。`,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return findings.length > 0 ? findings : null;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Rule: TODO/FIXME/HACK 堆积检测(故障 #9:技术债务积累)
|
|
576
|
+
* 过多的 TODO 标记表示技术债务在积累,需要定期清理。
|
|
577
|
+
*/
|
|
578
|
+
const tech_debt_markers = {
|
|
579
|
+
id: 'cq-tech-debt-markers',
|
|
580
|
+
severity: 'info',
|
|
581
|
+
category: 'code-quality',
|
|
582
|
+
check(ctx) {
|
|
583
|
+
if (!ctx.files) return null;
|
|
584
|
+
const findings = [];
|
|
585
|
+
const markerCounts = new Map(); // file -> count
|
|
586
|
+
|
|
587
|
+
for (const file of ctx.files) {
|
|
588
|
+
if (!file.content) continue;
|
|
589
|
+
const lines = file.content.split('\n');
|
|
590
|
+
let count = 0;
|
|
591
|
+
lines.forEach((line, i) => {
|
|
592
|
+
if (/\b(TODO|FIXME|HACK|XXX|WORKAROUND)\b/.test(line)) {
|
|
593
|
+
count++;
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
if (count >= 3) {
|
|
597
|
+
// Find first marker
|
|
598
|
+
for (let i = 0; i < lines.length; i++) {
|
|
599
|
+
if (/\b(TODO|FIXME|HACK|XXX|WORKAROUND)\b/.test(lines[i])) {
|
|
600
|
+
findings.push({
|
|
601
|
+
severity: 'info',
|
|
602
|
+
message: `文件中有 ${count} 处 TODO/FIXME/HACK 标记——技术债务在积累`,
|
|
603
|
+
file: file.path,
|
|
604
|
+
line: i + 1,
|
|
605
|
+
suggestion: `运行 devassist debt --scan 查看技术债务看板,制定清理计划。88条故障中23条与重构成本过高有关。`,
|
|
606
|
+
});
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return findings.length > 0 ? findings : null;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Rule: 魔法数字检测(故障 #8:可维护性差)
|
|
618
|
+
* 代码中直接出现的硬编码数字(非0、非1、非常见索引)应定义为常量。
|
|
619
|
+
*/
|
|
620
|
+
const magic_numbers = {
|
|
621
|
+
id: 'cq-magic-numbers',
|
|
622
|
+
severity: 'info',
|
|
623
|
+
category: 'code-quality',
|
|
624
|
+
check(ctx) {
|
|
625
|
+
if (!ctx.files) return null;
|
|
626
|
+
const findings = [];
|
|
627
|
+
for (const file of ctx.files) {
|
|
628
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
629
|
+
// Skip config/test/constant files
|
|
630
|
+
if (/config|test|spec|constant|threshold/i.test(file.path)) continue;
|
|
631
|
+
|
|
632
|
+
const lines = file.content.split('\n');
|
|
633
|
+
let magicCount = 0;
|
|
634
|
+
lines.forEach((line, i) => {
|
|
635
|
+
// Skip comments, imports, requires
|
|
636
|
+
if (/^\s*(\/\/|\/\*|\*|import|const|require)/.test(line)) return;
|
|
637
|
+
// Skip lines with process.env, config, or named constants
|
|
638
|
+
if (/process\.env|config\.|MAX_|MIN_|DEFAULT_|TIMEOUT|INTERVAL/i.test(line)) return;
|
|
639
|
+
|
|
640
|
+
// Detect standalone numbers that aren't 0, 1, -1, or common array indices
|
|
641
|
+
const numMatch = line.match(/[^.\w](\d{3,})\b(?!\s*(px|em|rem|%|ms|s|\b))/);
|
|
642
|
+
if (numMatch) {
|
|
643
|
+
const num = parseInt(numMatch[1]);
|
|
644
|
+
// Skip common port-like numbers (handled by hardcoded_config) and HTTP status codes
|
|
645
|
+
if (num >= 100 && num <= 599 && /^(200|301|302|304|400|401|403|404|500|502|503)$/.test(String(num))) return;
|
|
646
|
+
magicCount++;
|
|
647
|
+
if (magicCount <= 3) {
|
|
648
|
+
findings.push({
|
|
649
|
+
severity: 'info',
|
|
650
|
+
message: `魔法数字:${num}(应定义为具名常量)`,
|
|
651
|
+
file: file.path,
|
|
652
|
+
line: i + 1,
|
|
653
|
+
suggestion: `将 ${num} 提取为具名常量,如 const MAX_RETRY = ${num},提高代码可读性和可维护性`,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return findings.length > 0 ? findings : null;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Rule: 错误响应格式不一致检测(故障 #6:错误处理不统一)
|
|
665
|
+
* 检测不同的错误响应结构:有的用 {code, msg},有的用 {code, message},有的用 {error: ...}
|
|
666
|
+
*/
|
|
667
|
+
const error_format_inconsistency = {
|
|
668
|
+
id: 'cq-error-format-drift',
|
|
669
|
+
severity: 'warn',
|
|
670
|
+
category: 'convention',
|
|
671
|
+
check(ctx) {
|
|
672
|
+
if (!ctx.files) return null;
|
|
673
|
+
const findings = [];
|
|
674
|
+
const formats = new Map(); // format type -> [{file, line}]
|
|
675
|
+
|
|
676
|
+
for (const file of ctx.files) {
|
|
677
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
678
|
+
const content = file.content;
|
|
679
|
+
|
|
680
|
+
// Detect error response patterns
|
|
681
|
+
const patterns = [
|
|
682
|
+
{ regex: /code\s*:\s*\d{3}.*msg\s*:/g, type: 'code+msg' },
|
|
683
|
+
{ regex: /code\s*:\s*\d{3}.*message\s*:/g, type: 'code+message' },
|
|
684
|
+
{ regex: /error\s*:\s*['"`]/g, type: 'error:string' },
|
|
685
|
+
{ regex: /success\s*:\s*false/g, type: 'success:boolean' },
|
|
686
|
+
{ regex: /status\s*:\s*['"`](error|fail)/g, type: 'status:string' },
|
|
687
|
+
];
|
|
688
|
+
|
|
689
|
+
for (const { regex, type } of patterns) {
|
|
690
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
691
|
+
let match;
|
|
692
|
+
while ((match = re.exec(content)) !== null) {
|
|
693
|
+
if (!formats.has(type)) formats.set(type, []);
|
|
694
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
695
|
+
formats.get(type).push({ file: file.path, line });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// If 2+ different error formats are used
|
|
701
|
+
if (formats.size >= 2) {
|
|
702
|
+
const allFormats = [...formats.keys()].join(' / ');
|
|
703
|
+
for (const [type, locations] of formats) {
|
|
704
|
+
for (const loc of locations.slice(0, 2)) {
|
|
705
|
+
findings.push({
|
|
706
|
+
severity: 'warn',
|
|
707
|
+
message: `错误响应格式不一致:项目中同时使用了 ${allFormats}(当前:${type})`,
|
|
708
|
+
file: loc.file,
|
|
709
|
+
line: loc.line,
|
|
710
|
+
suggestion: `统一错误响应格式(推荐 { code: number, msg: string, data: any })。运行 devassist convention add --id error-format --rule "统一使用 code+msg 格式" 来强制约定`,
|
|
711
|
+
context: { allFormats, currentType: type },
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return findings.length > 0 ? findings : null;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Rule: 文件大幅缩减检测(故障 #1 根因:AI 覆盖文件)
|
|
722
|
+
* 如果代码中出现 fs.writeFile 写入一个大字符串常量到文件,可能是 AI 在覆盖整个文件。
|
|
723
|
+
*/
|
|
724
|
+
const dangerous_file_overwrite = {
|
|
725
|
+
id: 'cq-dangerous-overwrite',
|
|
726
|
+
severity: 'warn',
|
|
727
|
+
category: 'code-quality',
|
|
728
|
+
check(ctx) {
|
|
729
|
+
if (!ctx.files) return null;
|
|
730
|
+
const findings = [];
|
|
731
|
+
for (const file of ctx.files) {
|
|
732
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
733
|
+
const content = file.content;
|
|
734
|
+
|
|
735
|
+
// Detect fs.writeFileSync with a template literal or string concatenation > 500 chars
|
|
736
|
+
const writeMatch = content.match(/fs\.writeFileSync\s*\(\s*[^,]+,\s*([`'"])/);
|
|
737
|
+
if (writeMatch) {
|
|
738
|
+
const quote = writeMatch[1];
|
|
739
|
+
// Find the start of the writeFileSync call
|
|
740
|
+
const startIdx = writeMatch.index;
|
|
741
|
+
const line = content.substring(0, startIdx).split('\n').length;
|
|
742
|
+
|
|
743
|
+
// Check if it's writing a very large string literal
|
|
744
|
+
if (quote === '`') {
|
|
745
|
+
// Template literal - find matching backtick
|
|
746
|
+
const afterWrite = content.substring(writeMatch.index + writeMatch[0].length);
|
|
747
|
+
const endTick = afterWrite.indexOf('`');
|
|
748
|
+
if (endTick > 500) {
|
|
749
|
+
findings.push({
|
|
750
|
+
severity: 'warn',
|
|
751
|
+
message: `fs.writeFileSync 写入了大段模板字符串(${endTick} 字符)——疑似 AI 整体覆盖文件`,
|
|
752
|
+
file: file.path,
|
|
753
|
+
line,
|
|
754
|
+
suggestion: `避免用代码生成大段文件内容。88条故障中 AI 将 app.js 从 2720 行覆盖为 106 行是 #1 根因。`,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return findings.length > 0 ? findings : null;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// ========== Phase 5 新增规则:安全漏洞 + 性能反模式 ==========
|
|
765
|
+
|
|
766
|
+
// --- 安全漏洞检测 (Security) ---
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Rule: eval / Function 构造器使用(代码注入风险)
|
|
770
|
+
* eval() 和 new Function() 会执行任意代码,是最高危的安全漏洞。
|
|
771
|
+
*/
|
|
772
|
+
const sec_eval_usage = {
|
|
773
|
+
id: 'sec-eval-usage',
|
|
774
|
+
severity: 'block',
|
|
775
|
+
category: 'security',
|
|
776
|
+
check(ctx) {
|
|
777
|
+
if (!ctx.files) return null;
|
|
778
|
+
const findings = [];
|
|
779
|
+
for (const file of ctx.files) {
|
|
780
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
781
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
782
|
+
const lines = file.content.split('\n');
|
|
783
|
+
lines.forEach((line, i) => {
|
|
784
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
785
|
+
// eval( or new Function(
|
|
786
|
+
if (/\beval\s*\(/.test(line) || /\bnew\s+Function\s*\(/.test(line)) {
|
|
787
|
+
findings.push({
|
|
788
|
+
severity: 'block',
|
|
789
|
+
message: `eval/Function 构造器使用——存在代码注入风险`,
|
|
790
|
+
file: file.path,
|
|
791
|
+
line: i + 1,
|
|
792
|
+
suggestion: `移除 eval/new Function,改用 JSON.parse 解析数据,或用 Map/查找表替代动态代码执行`,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return findings.length > 0 ? findings : null;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Rule: SQL 注入风险——字符串拼接构建 SQL 语句
|
|
803
|
+
*/
|
|
804
|
+
const sec_sql_injection = {
|
|
805
|
+
id: 'sec-sql-injection',
|
|
806
|
+
severity: 'block',
|
|
807
|
+
category: 'security',
|
|
808
|
+
check(ctx) {
|
|
809
|
+
if (!ctx.files) return null;
|
|
810
|
+
const findings = [];
|
|
811
|
+
for (const file of ctx.files) {
|
|
812
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
813
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
814
|
+
const lines = file.content.split('\n');
|
|
815
|
+
lines.forEach((line, i) => {
|
|
816
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
817
|
+
// Pattern: query(`SELECT ... ${var}`) or query('SELECT ...' + var)
|
|
818
|
+
const hasSQL = /\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\b/i.test(line);
|
|
819
|
+
const hasInterp = /\$\{/.test(line) || (/\+\s*\w/.test(line) && /['"`]/.test(line));
|
|
820
|
+
const hasParam = /\$\d|placeholder|\?/.test(line);
|
|
821
|
+
if (hasSQL && hasInterp && !hasParam) {
|
|
822
|
+
findings.push({
|
|
823
|
+
severity: 'block',
|
|
824
|
+
message: `SQL 语句使用了字符串拼接——存在 SQL 注入风险`,
|
|
825
|
+
file: file.path,
|
|
826
|
+
line: i + 1,
|
|
827
|
+
suggestion: `使用参数化查询:query('SELECT * FROM t WHERE id = ?', [id]),禁止直接拼接用户输入`,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return findings.length > 0 ? findings : null;
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Rule: 硬编码密钥/密码/Token 检测
|
|
838
|
+
*/
|
|
839
|
+
const sec_hardcoded_secrets = {
|
|
840
|
+
id: 'sec-hardcoded-secrets',
|
|
841
|
+
severity: 'block',
|
|
842
|
+
category: 'security',
|
|
843
|
+
check(ctx) {
|
|
844
|
+
if (!ctx.files) return null;
|
|
845
|
+
const findings = [];
|
|
846
|
+
// Patterns that indicate hardcoded secrets
|
|
847
|
+
const secretPatterns = [
|
|
848
|
+
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{6,}['"]/i, label: '密码' },
|
|
849
|
+
{ regex: /(?:secret|api[_-]?key|access[_-]?key)\s*[:=]\s*['"][^'"]{10,}['"]/i, label: 'API密钥' },
|
|
850
|
+
{ regex: /(?:token|bearer)\s*[:=]\s*['"][^'"]{15,}['"]/i, label: 'Token' },
|
|
851
|
+
{ regex: /(?:private[_-]?key)\s*[:=]\s*['"][^'"]{20,}['"]/i, label: '私钥' },
|
|
852
|
+
{ regex: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/, label: 'PEM私钥' },
|
|
853
|
+
];
|
|
854
|
+
for (const file of ctx.files) {
|
|
855
|
+
if (!file.content || !/\.(js|ts|jsx|tsx|json|env)$/.test(file.path)) continue;
|
|
856
|
+
// Skip config templates and test files
|
|
857
|
+
if (/template|\.example|test|spec|\.env\.example/i.test(file.path)) continue;
|
|
858
|
+
// Skip ai-config.json (stores API keys by design)
|
|
859
|
+
if (/ai-config\.json$/.test(file.path)) continue;
|
|
860
|
+
const lines = file.content.split('\n');
|
|
861
|
+
lines.forEach((line, i) => {
|
|
862
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
863
|
+
// Skip env var references
|
|
864
|
+
if (/process\.env/.test(line)) return;
|
|
865
|
+
for (const { regex, label } of secretPatterns) {
|
|
866
|
+
const re = new RegExp(regex.source, 'i');
|
|
867
|
+
if (re.test(line)) {
|
|
868
|
+
findings.push({
|
|
869
|
+
severity: 'block',
|
|
870
|
+
message: `疑似硬编码${label}——密钥不应出现在源码中`,
|
|
871
|
+
file: file.path,
|
|
872
|
+
line: i + 1,
|
|
873
|
+
suggestion: `将${label}移到环境变量或 .env 文件(加入 .gitignore),代码中通过 process.env 读取`,
|
|
874
|
+
});
|
|
875
|
+
break; // one per line
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return findings.length > 0 ? findings : null;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Rule: 路径穿越风险——用户输入直接拼入文件路径
|
|
886
|
+
*/
|
|
887
|
+
const sec_path_traversal = {
|
|
888
|
+
id: 'sec-path-traversal',
|
|
889
|
+
severity: 'warn',
|
|
890
|
+
category: 'security',
|
|
891
|
+
check(ctx) {
|
|
892
|
+
if (!ctx.files) return null;
|
|
893
|
+
const findings = [];
|
|
894
|
+
for (const file of ctx.files) {
|
|
895
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
896
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
897
|
+
const lines = file.content.split('\n');
|
|
898
|
+
lines.forEach((line, i) => {
|
|
899
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
900
|
+
// Pattern: path.join/resolve with req.body/req.params/req.query/userInput
|
|
901
|
+
const hasPathOp = /\bpath\.(join|resolve|normalize)\s*\(/.test(line);
|
|
902
|
+
const hasUserInput = /req\.(body|params|query|headers)|userInput|userInput|filename|filepath/i.test(line);
|
|
903
|
+
const hasSanitize = /sanitize|normalize|replace\(\.\./.test(line);
|
|
904
|
+
if (hasPathOp && hasUserInput && !hasSanitize) {
|
|
905
|
+
findings.push({
|
|
906
|
+
severity: 'warn',
|
|
907
|
+
message: `文件路径包含用户输入——存在路径穿越风险`,
|
|
908
|
+
file: file.path,
|
|
909
|
+
line: i + 1,
|
|
910
|
+
suggestion: `对用户输入的路径做 sanitize:移除 ../ 和绝对路径前缀,或用 path.relative 验证最终路径在允许范围内`,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return findings.length > 0 ? findings : null;
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Rule: 命令注入风险——exec/spawn 使用字符串拼接
|
|
921
|
+
*/
|
|
922
|
+
const sec_command_injection = {
|
|
923
|
+
id: 'sec-command-injection',
|
|
924
|
+
severity: 'block',
|
|
925
|
+
category: 'security',
|
|
926
|
+
check(ctx) {
|
|
927
|
+
if (!ctx.files) return null;
|
|
928
|
+
const findings = [];
|
|
929
|
+
for (const file of ctx.files) {
|
|
930
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
931
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
932
|
+
const lines = file.content.split('\n');
|
|
933
|
+
lines.forEach((line, i) => {
|
|
934
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
935
|
+
// exec(`cmd ${var}`) or execSync('cmd ' + var)
|
|
936
|
+
const hasExec = /\b(exec|execSync|spawn|spawnSync)\s*\(/.test(line);
|
|
937
|
+
const hasInterp = /\$\{/.test(line) || (/\+\s*\w/.test(line) && /['"`]/.test(line));
|
|
938
|
+
const hasArrayForm = /\[\s*\]/.test(line); // spawn with array args is safe
|
|
939
|
+
if (hasExec && hasInterp && !hasArrayForm) {
|
|
940
|
+
findings.push({
|
|
941
|
+
severity: 'block',
|
|
942
|
+
message: `exec/spawn 使用字符串拼接——存在命令注入风险`,
|
|
943
|
+
file: file.path,
|
|
944
|
+
line: i + 1,
|
|
945
|
+
suggestion: `使用 execFile/spawn 的数组参数形式:spawn('cmd', [arg1, arg2]),禁止字符串拼接 shell 命令`,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
return findings.length > 0 ? findings : null;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Rule: 不安全加密——弱哈希算法 / 弱随机数
|
|
956
|
+
*/
|
|
957
|
+
const sec_insecure_crypto = {
|
|
958
|
+
id: 'sec-insecure-crypto',
|
|
959
|
+
severity: 'warn',
|
|
960
|
+
category: 'security',
|
|
961
|
+
check(ctx) {
|
|
962
|
+
if (!ctx.files) return null;
|
|
963
|
+
const findings = [];
|
|
964
|
+
for (const file of ctx.files) {
|
|
965
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
966
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
967
|
+
const lines = file.content.split('\n');
|
|
968
|
+
lines.forEach((line, i) => {
|
|
969
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
970
|
+
// MD5 / SHA1 for password hashing
|
|
971
|
+
if (/\bcreateHash\s*\(\s*['"`](md5|sha1)['"`]/.test(line)) {
|
|
972
|
+
findings.push({
|
|
973
|
+
severity: 'warn',
|
|
974
|
+
message: `使用了弱哈希算法 (MD5/SHA1)——不适用于密码存储`,
|
|
975
|
+
file: file.path,
|
|
976
|
+
line: i + 1,
|
|
977
|
+
suggestion: `密码存储使用 bcrypt/scrypt/argon2,数据校验使用 SHA-256+。MD5/SHA1 已被证明不安全`,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
// Math.random() for security purposes
|
|
981
|
+
if (/Math\.random\s*\(/.test(line) && /token|secret|key|password|id|nonce/i.test(line)) {
|
|
982
|
+
findings.push({
|
|
983
|
+
severity: 'warn',
|
|
984
|
+
message: `Math.random() 用于安全场景——密码学不安全的随机数`,
|
|
985
|
+
file: file.path,
|
|
986
|
+
line: i + 1,
|
|
987
|
+
suggestion: `安全场景使用 crypto.randomBytes() 或 crypto.randomUUID(),Math.random() 不是密码学安全的`,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
return findings.length > 0 ? findings : null;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Rule: XSS 风险——innerHTML / document.write 使用用户输入
|
|
998
|
+
*/
|
|
999
|
+
const sec_xss_risk = {
|
|
1000
|
+
id: 'sec-xss-risk',
|
|
1001
|
+
severity: 'block',
|
|
1002
|
+
category: 'security',
|
|
1003
|
+
check(ctx) {
|
|
1004
|
+
if (!ctx.files) return null;
|
|
1005
|
+
const findings = [];
|
|
1006
|
+
for (const file of ctx.files) {
|
|
1007
|
+
if (!file.content || !/\.(js|ts|jsx|tsx|html)$/.test(file.path)) continue;
|
|
1008
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1009
|
+
const lines = file.content.split('\n');
|
|
1010
|
+
lines.forEach((line, i) => {
|
|
1011
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
1012
|
+
// innerHTML = userInput or document.write(userInput)
|
|
1013
|
+
const hasSink = /\.innerHTML\s*=|document\.write\s*\(/.test(line);
|
|
1014
|
+
const hasUserInput = /req\.(body|params|query)|userInput|location\.(search|hash)|innerHTML/i.test(line);
|
|
1015
|
+
const hasEscape = /escape|sanitize|encode|textContent|DOMPurify/i.test(line);
|
|
1016
|
+
if (hasSink && !hasEscape) {
|
|
1017
|
+
findings.push({
|
|
1018
|
+
severity: 'block',
|
|
1019
|
+
message: `innerHTML/document.write 直接赋值——存在 XSS 风险`,
|
|
1020
|
+
file: file.path,
|
|
1021
|
+
line: i + 1,
|
|
1022
|
+
suggestion: `使用 textContent 替代 innerHTML,或对用户输入做 HTML 转义(< > & ")`,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return findings.length > 0 ? findings : null;
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Rule: 原型链污染——Object.assign / merge with 用户输入
|
|
1033
|
+
*/
|
|
1034
|
+
const sec_proto_pollution = {
|
|
1035
|
+
id: 'sec-proto-pollution',
|
|
1036
|
+
severity: 'warn',
|
|
1037
|
+
category: 'security',
|
|
1038
|
+
check(ctx) {
|
|
1039
|
+
if (!ctx.files) return null;
|
|
1040
|
+
const findings = [];
|
|
1041
|
+
for (const file of ctx.files) {
|
|
1042
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1043
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1044
|
+
const lines = file.content.split('\n');
|
|
1045
|
+
lines.forEach((line, i) => {
|
|
1046
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
1047
|
+
// Object.assign({}, req.body) or deep merge with user input
|
|
1048
|
+
const hasMerge = /Object\.assign\s*\(|\bmerge\s*\(|\bextend\s*\(/.test(line);
|
|
1049
|
+
const hasUserInput = /req\.(body|params|query)|JSON\.parse|userInput/i.test(line);
|
|
1050
|
+
const hasProtect = /Object\.create\(null\)|\.__proto__|hasOwnProperty/.test(line);
|
|
1051
|
+
if (hasMerge && hasUserInput && !hasProtect) {
|
|
1052
|
+
findings.push({
|
|
1053
|
+
severity: 'warn',
|
|
1054
|
+
message: `Object.assign/merge 使用用户输入——存在原型链污染风险`,
|
|
1055
|
+
file: file.path,
|
|
1056
|
+
line: i + 1,
|
|
1057
|
+
suggestion: `过滤 __proto__ 和 constructor 键名,或使用 Object.create(null) 创建安全对象`,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
return findings.length > 0 ? findings : null;
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// --- 性能反模式检测 (Performance) ---
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Rule: 异步上下文中的同步 I/O——阻塞事件循环
|
|
1070
|
+
*/
|
|
1071
|
+
const perf_sync_io_in_async = {
|
|
1072
|
+
id: 'perf-sync-io-in-async',
|
|
1073
|
+
severity: 'warn',
|
|
1074
|
+
category: 'performance',
|
|
1075
|
+
check(ctx) {
|
|
1076
|
+
if (!ctx.files) return null;
|
|
1077
|
+
const findings = [];
|
|
1078
|
+
for (const file of ctx.files) {
|
|
1079
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1080
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1081
|
+
const lines = file.content.split('\n');
|
|
1082
|
+
let inAsync = false;
|
|
1083
|
+
let asyncStartLine = 0;
|
|
1084
|
+
lines.forEach((line, i) => {
|
|
1085
|
+
// Track async function context
|
|
1086
|
+
const asyncMatch = line.match(/\basync\s+(function\s+)?\w*\s*\(|async\s+=>/);
|
|
1087
|
+
if (asyncMatch) { inAsync = true; asyncStartLine = i; }
|
|
1088
|
+
if (inAsync && /^\}/.test(line.trim())) inAsync = false;
|
|
1089
|
+
|
|
1090
|
+
if (!inAsync) return;
|
|
1091
|
+
// fs.readFileSync / fs.writeFileSync inside async function
|
|
1092
|
+
if (/\bfs\.(readFileSync|writeFileSync|existsSync|statSync|readdirSync|unlinkSync|mkdirSync)\s*\(/.test(line)) {
|
|
1093
|
+
findings.push({
|
|
1094
|
+
severity: 'warn',
|
|
1095
|
+
message: `异步函数中使用同步 I/O(${line.match(/fs\.(\w+Sync)/)?.[1] || 'Sync'})——阻塞事件循环`,
|
|
1096
|
+
file: file.path,
|
|
1097
|
+
line: i + 1,
|
|
1098
|
+
suggestion: `改用异步版本:fs.promises.readFile / fs.promises.writeFile,避免阻塞 Node.js 事件循环`,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
return findings.length > 0 ? findings : null;
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Rule: 循环中的 await——N+1 查询模式
|
|
1109
|
+
*/
|
|
1110
|
+
const perf_n_plus_1 = {
|
|
1111
|
+
id: 'perf-n-plus-1',
|
|
1112
|
+
severity: 'warn',
|
|
1113
|
+
category: 'performance',
|
|
1114
|
+
check(ctx) {
|
|
1115
|
+
if (!ctx.files) return null;
|
|
1116
|
+
const findings = [];
|
|
1117
|
+
for (const file of ctx.files) {
|
|
1118
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1119
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1120
|
+
const lines = file.content.split('\n');
|
|
1121
|
+
let inLoop = false;
|
|
1122
|
+
let loopStartLine = 0;
|
|
1123
|
+
lines.forEach((line, i) => {
|
|
1124
|
+
// Track loop context
|
|
1125
|
+
if (/\b(for|while)\s*\(/.test(line)) { inLoop = true; loopStartLine = i; }
|
|
1126
|
+
if (inLoop && /^\s*\}/.test(line)) inLoop = false;
|
|
1127
|
+
|
|
1128
|
+
if (!inLoop) return;
|
|
1129
|
+
// await inside loop
|
|
1130
|
+
if (/\bawait\b/.test(line)) {
|
|
1131
|
+
// Check if it's a Promise.all pattern (which is OK)
|
|
1132
|
+
const context = lines.slice(loopStartLine, i + 1).join('\n');
|
|
1133
|
+
if (/Promise\.all|map\s*\(\s*async/.test(context)) return;
|
|
1134
|
+
findings.push({
|
|
1135
|
+
severity: 'warn',
|
|
1136
|
+
message: `循环内使用 await——N+1 查询模式,性能瓶颈`,
|
|
1137
|
+
file: file.path,
|
|
1138
|
+
line: i + 1,
|
|
1139
|
+
suggestion: `用 Promise.all 批量并行请求,或改为批量查询:const results = await Promise.all(items.map(fetchItem))`,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
return findings.length > 0 ? findings : null;
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Rule: 字符串拼接循环——应使用数组 join
|
|
1150
|
+
*/
|
|
1151
|
+
const perf_string_concat_loop = {
|
|
1152
|
+
id: 'perf-string-concat-loop',
|
|
1153
|
+
severity: 'info',
|
|
1154
|
+
category: 'performance',
|
|
1155
|
+
check(ctx) {
|
|
1156
|
+
if (!ctx.files) return null;
|
|
1157
|
+
const findings = [];
|
|
1158
|
+
for (const file of ctx.files) {
|
|
1159
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1160
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1161
|
+
const lines = file.content.split('\n');
|
|
1162
|
+
let inLoop = false;
|
|
1163
|
+
let loopStartLine = 0;
|
|
1164
|
+
lines.forEach((line, i) => {
|
|
1165
|
+
if (/\b(for|while)\s*\(/.test(line)) { inLoop = true; loopStartLine = i; }
|
|
1166
|
+
if (inLoop && /^\s*\}/.test(line)) inLoop = false;
|
|
1167
|
+
if (!inLoop) return;
|
|
1168
|
+
// str += ... inside loop
|
|
1169
|
+
if (/(\w+)\s*\+=\s*['"`]/.test(line) || /(\w+)\s*=\s*\w+\s*\+\s*['"`]/.test(line)) {
|
|
1170
|
+
findings.push({
|
|
1171
|
+
severity: 'info',
|
|
1172
|
+
message: `循环内字符串拼接——每次拼接都创建新字符串,O(n²) 复杂度`,
|
|
1173
|
+
file: file.path,
|
|
1174
|
+
line: i + 1,
|
|
1175
|
+
suggestion: `用数组收集后 join:const parts = []; loop { parts.push(str) } result = parts.join('')`,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
return findings.length > 0 ? findings : null;
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Rule: 循环内重复编译正则表达式
|
|
1186
|
+
*/
|
|
1187
|
+
const perf_regex_in_loop = {
|
|
1188
|
+
id: 'perf-regex-in-loop',
|
|
1189
|
+
severity: 'info',
|
|
1190
|
+
category: 'performance',
|
|
1191
|
+
check(ctx) {
|
|
1192
|
+
if (!ctx.files) return null;
|
|
1193
|
+
const findings = [];
|
|
1194
|
+
for (const file of ctx.files) {
|
|
1195
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1196
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1197
|
+
const lines = file.content.split('\n');
|
|
1198
|
+
let inLoop = false;
|
|
1199
|
+
lines.forEach((line, i) => {
|
|
1200
|
+
if (/\b(for|while)\s*\(/.test(line)) { inLoop = true; }
|
|
1201
|
+
if (inLoop && /^\s*\}/.test(line)) inLoop = false;
|
|
1202
|
+
if (!inLoop) return;
|
|
1203
|
+
// new RegExp( inside loop
|
|
1204
|
+
if (/\bnew\s+RegExp\s*\(/.test(line)) {
|
|
1205
|
+
findings.push({
|
|
1206
|
+
severity: 'info',
|
|
1207
|
+
message: `循环内 new RegExp()——每次迭代都重新编译正则`,
|
|
1208
|
+
file: file.path,
|
|
1209
|
+
line: i + 1,
|
|
1210
|
+
suggestion: `将正则声明移到循环外部:const re = /pattern/g; 然后在循环内使用 re.test()`,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
return findings.length > 0 ? findings : null;
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Rule: JSON.parse 缺少校验——解析失败会导致崩溃
|
|
1221
|
+
*/
|
|
1222
|
+
const perf_json_parse_unsafe = {
|
|
1223
|
+
id: 'perf-json-parse-unsafe',
|
|
1224
|
+
severity: 'warn',
|
|
1225
|
+
category: 'performance',
|
|
1226
|
+
check(ctx) {
|
|
1227
|
+
if (!ctx.files) return null;
|
|
1228
|
+
const findings = [];
|
|
1229
|
+
for (const file of ctx.files) {
|
|
1230
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1231
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1232
|
+
const lines = file.content.split('\n');
|
|
1233
|
+
lines.forEach((line, i) => {
|
|
1234
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
1235
|
+
// JSON.parse without try/catch
|
|
1236
|
+
if (/\bJSON\.parse\s*\(/.test(line)) {
|
|
1237
|
+
const context = lines.slice(Math.max(0, i - 3), i + 1).join('\n');
|
|
1238
|
+
if (!/try\s*\{|catch/.test(context)) {
|
|
1239
|
+
findings.push({
|
|
1240
|
+
severity: 'warn',
|
|
1241
|
+
message: `JSON.parse 未被 try/catch 包裹——解析失败会导致应用崩溃`,
|
|
1242
|
+
file: file.path,
|
|
1243
|
+
line: i + 1,
|
|
1244
|
+
suggestion: `用 try/catch 包裹 JSON.parse,或使用安全的解析函数:const safeParse = (s) => { try { return JSON.parse(s) } catch { return null } }`,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
return findings.length > 0 ? findings : null;
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Rule: 大量数据同步处理——缺少分页/流式处理
|
|
1256
|
+
*/
|
|
1257
|
+
const perf_no_pagination = {
|
|
1258
|
+
id: 'perf-no-pagination',
|
|
1259
|
+
severity: 'warn',
|
|
1260
|
+
category: 'performance',
|
|
1261
|
+
check(ctx) {
|
|
1262
|
+
if (!ctx.files) return null;
|
|
1263
|
+
const findings = [];
|
|
1264
|
+
for (const file of ctx.files) {
|
|
1265
|
+
if (!file.content || !/\.(js|ts|jsx|tsx)$/.test(file.path)) continue;
|
|
1266
|
+
if (/node_modules|[\/\\](tests?|specs?)[\/\\]/.test(file.path)) continue;
|
|
1267
|
+
const lines = file.content.split('\n');
|
|
1268
|
+
lines.forEach((line, i) => {
|
|
1269
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) return;
|
|
1270
|
+
// Pattern: SELECT * without LIMIT, or .find() without .limit()
|
|
1271
|
+
const hasSelectAll = /\bSELECT\s+\*/i.test(line);
|
|
1272
|
+
const hasNoLimit = !/\bLIMIT\b/i.test(line);
|
|
1273
|
+
const hasNoOffset = !/\bOFFSET\b/i.test(line);
|
|
1274
|
+
if (hasSelectAll && hasNoLimit) {
|
|
1275
|
+
findings.push({
|
|
1276
|
+
severity: 'warn',
|
|
1277
|
+
message: `SELECT * 无 LIMIT——全表扫描可能导致内存溢出`,
|
|
1278
|
+
file: file.path,
|
|
1279
|
+
line: i + 1,
|
|
1280
|
+
suggestion: `添加分页:SELECT * FROM t LIMIT 20 OFFSET 0,或使用游标分页`,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
// .find() / .findAll() without .limit()
|
|
1284
|
+
if (/\.(find|findAll|selectAll)\s*\(/.test(line) && !/\.limit\s*\(/.test(lines.slice(i, Math.min(i + 5, lines.length)).join('\n'))) {
|
|
1285
|
+
// Only flag if it looks like a DB query (has await or model reference)
|
|
1286
|
+
if (/\bawait\b/.test(line) || /Model|model|repository|repo/i.test(line)) {
|
|
1287
|
+
findings.push({
|
|
1288
|
+
severity: 'warn',
|
|
1289
|
+
message: `查询无 limit()——大量数据时可能导致内存溢出`,
|
|
1290
|
+
file: file.path,
|
|
1291
|
+
line: i + 1,
|
|
1292
|
+
suggestion: `添加分页:Model.find(filter).limit(20).skip(offset)`,
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
return findings.length > 0 ? findings : null;
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
// Export all rules for registration
|
|
1303
|
+
const codeQualityRules = [
|
|
1304
|
+
// 原有规则
|
|
1305
|
+
rule_id_class_confusion,
|
|
1306
|
+
rule_null_safety,
|
|
1307
|
+
rule_missing_trycatch,
|
|
1308
|
+
race_condition,
|
|
1309
|
+
duplicate_declaration,
|
|
1310
|
+
file_too_large,
|
|
1311
|
+
// Phase 3 新增规则(基于88条故障扩展)
|
|
1312
|
+
api_response_naming, // API 响应键名漂移(32/88 约定遗忘根因)
|
|
1313
|
+
fetch_no_catch, // fetch 缺少错误处理(6/88 防御性编程)
|
|
1314
|
+
hardcoded_config, // 硬编码配置值(1,3,13/88 部署问题)
|
|
1315
|
+
console_log_residual, // console.log 残留
|
|
1316
|
+
missing_await, // async 调用缺少 await
|
|
1317
|
+
event_listener_leak, // 事件监听无清理(4/88 竞态条件)
|
|
1318
|
+
tech_debt_markers, // TODO/FIXME 堆积(23/88 重构成本)
|
|
1319
|
+
magic_numbers, // 魔法数字
|
|
1320
|
+
error_format_inconsistency, // 错误响应格式不一致
|
|
1321
|
+
dangerous_file_overwrite, // 危险文件覆盖(41/88 局部优化根因)
|
|
1322
|
+
// Phase 5 新增:安全漏洞检测
|
|
1323
|
+
sec_eval_usage, // eval/Function 代码注入
|
|
1324
|
+
sec_sql_injection, // SQL 注入
|
|
1325
|
+
sec_hardcoded_secrets, // 硬编码密钥/密码
|
|
1326
|
+
sec_path_traversal, // 路径穿越
|
|
1327
|
+
sec_command_injection, // 命令注入
|
|
1328
|
+
sec_insecure_crypto, // 不安全加密
|
|
1329
|
+
sec_xss_risk, // XSS 风险
|
|
1330
|
+
sec_proto_pollution, // 原型链污染
|
|
1331
|
+
// Phase 5 新增:性能反模式检测
|
|
1332
|
+
perf_sync_io_in_async, // 异步中同步 I/O
|
|
1333
|
+
perf_n_plus_1, // N+1 查询
|
|
1334
|
+
perf_string_concat_loop, // 循环内字符串拼接
|
|
1335
|
+
perf_regex_in_loop, // 循环内编译正则
|
|
1336
|
+
perf_json_parse_unsafe, // JSON.parse 无 try/catch
|
|
1337
|
+
perf_no_pagination, // 无分页查询
|
|
1338
|
+
];
|
|
1339
|
+
|
|
1340
|
+
module.exports = { codeQualityRules, CodeQualityGuard: { rules: codeQualityRules } };
|