@raolin2025/claude-code-node 2.0.0 → 2.2.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.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * PR 自动审查引擎
3
+ * 结合规则检查 + LLM 智能评估
4
+ */
5
+
6
+ import { parseDiff, splitDiffByFile, countFileChanges } from './utils/diff-parser.js'
7
+ import { GitHubAPI } from './github-api.js'
8
+ import { callLLM, buildPRReviewPrompt } from './llm-assistant.js'
9
+
10
+ /**
11
+ * PR 审查器
12
+ */
13
+ export class PRReviewer {
14
+ /**
15
+ * @param {GitHubAPI} github - GitHub API 实例
16
+ * @param {Object} rules - 审查规则配置
17
+ */
18
+ constructor(github, rules = {}, options = {}) {
19
+ this.github = github
20
+ this.rules = {
21
+ checks: {
22
+ codeQuality: true,
23
+ security: true,
24
+ tests: true,
25
+ docs: true,
26
+ complexity: true,
27
+ duplication: true
28
+ },
29
+ ...rules
30
+ }
31
+ this.apiConfig = options.apiConfig || {}
32
+ this.enableLLM = options.enableLLM !== false
33
+ }
34
+
35
+ /**
36
+ * 审查 PR
37
+ * @param {number} prNumber
38
+ * @param {Object} options - 审查选项
39
+ * @returns {Promise<ReviewResult>}
40
+ */
41
+ async reviewPR(prNumber, options = {}) {
42
+ const {
43
+ analyzeLLM = true, // 是否使用 LLM 智能分析
44
+ commentThreshold = 'WARNING' // 'INFO' | 'WARNING' | 'ERROR'
45
+ } = options
46
+
47
+ // 1. 获取 PR 数据
48
+ const pr = await this.github.getPR(prNumber)
49
+ const diff = await this.github.getPRDiff(prNumber)
50
+ const files = await this.github.getPRFiles(prNumber)
51
+
52
+ // 2. 解析 diff
53
+ const fileMap = splitDiffByFile(diff)
54
+ const allHunks = parseDiff(diff)
55
+
56
+ // 3. 运行规则检查
57
+ const findings = await this.runAllChecks(pr, files, diff, fileMap)
58
+
59
+ // 4. LLM 智能分析(可选)
60
+ let llmSummary = null
61
+ if (analyzeLLM && this.enableLLM && findings.length > 0) {
62
+ llmSummary = await this.llmAnalysis(pr, findings, diff)
63
+ } else if (findings.length === 0) {
64
+ llmSummary = { summary: '✅ No issues found', riskLevel: 'LOW', recommendations: ['PR looks good to merge'] }
65
+ }
66
+
67
+ // 5. 生成审查报告
68
+ const report = this.generateReviewReport(pr, findings, llmSummary)
69
+
70
+ // 6. 准备评论列表(根据 threshold 过滤)
71
+ const comments = findings
72
+ .filter(f => this.shouldComment(f, commentThreshold))
73
+ .map(f => this.buildReviewComment(f))
74
+
75
+ return {
76
+ prNumber,
77
+ prTitle: pr.title,
78
+ prBody: pr.body,
79
+ findings,
80
+ summary: report,
81
+ llmSummary,
82
+ comments,
83
+ meta: {
84
+ totalFiles: files.length,
85
+ totalLinesChanged: files.reduce((sum, f) => sum + f.additions + f.deletions, 0),
86
+ reviewedAt: new Date().toISOString()
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 执行所有检查
93
+ */
94
+ async runAllChecks(pr, files, diff, fileMap) {
95
+ const findings = []
96
+
97
+ // 并行运行所有检查器
98
+ const checks = []
99
+
100
+ if (this.rules.checks.codeQuality) {
101
+ checks.push(this.checkCodeQuality(files, fileMap))
102
+ }
103
+ if (this.rules.checks.security) {
104
+ checks.push(this.checkSecurity(files, fileMap))
105
+ }
106
+ if (this.rules.checks.tests) {
107
+ checks.push(this.checkTests(files, fileMap))
108
+ }
109
+ if (this.rules.checks.docs) {
110
+ checks.push(this.checkDocs(pr, files, fileMap))
111
+ }
112
+ if (this.rules.checks.complexity) {
113
+ checks.push(this.checkComplexity(files, fileMap))
114
+ }
115
+ if (this.rules.checks.duplication) {
116
+ checks.push(this.checkDuplication(files, fileMap))
117
+ }
118
+
119
+ const results = await Promise.all(checks)
120
+ findings.push(...results.flat())
121
+
122
+ return findings
123
+ }
124
+
125
+ // ============ 检查器实现 ============
126
+
127
+ /**
128
+ * 代码质量检查
129
+ */
130
+ async checkCodeQuality(files, fileMap) {
131
+ const findings = []
132
+ const patterns = {
133
+ 'console.log': { severity: 'WARNING', message: 'Remove console.log before merging' },
134
+ 'debugger': { severity: 'ERROR', message: 'Remove debugger statements' },
135
+ 'FIXME': { severity: 'INFO', message: 'FIXME comment found' },
136
+ 'TODO': { severity: 'INFO', message: 'TODO comment found' },
137
+ 'XXX': { severity: 'WARNING', message: 'XXX comment indicates incomplete code' },
138
+ 'var ': { severity: 'WARNING', message: 'Consider using const/let instead of var' },
139
+ '===': { severity: 'WARNING', message: 'Strict equality === is fine, but consider type-safe comparison' } // false positive demo
140
+ }
141
+
142
+ for (const [filePath, hunks] of fileMap.entries()) {
143
+ const isCodeFile = this.isCodeFile(filePath)
144
+ if (!isCodeFile) continue
145
+
146
+ const changes = countFileChanges(hunks)
147
+ if (changes.changes === 0) continue
148
+
149
+ for (const hunk of hunks) {
150
+ for (const line of hunk.lines) {
151
+ if (line.type !== '+') continue // 只检查新增的代码
152
+
153
+ const content = line.content
154
+ for (const [pattern, info] of Object.entries(patterns)) {
155
+ if (content.includes(pattern)) {
156
+ findings.push({
157
+ type: 'code-quality',
158
+ severity: info.severity,
159
+ file: filePath,
160
+ line: line.newLineNum,
161
+ column: 0,
162
+ message: `${info.message} (line: ${content.trim()})`,
163
+ snippet: content.trim(),
164
+ rule: pattern
165
+ })
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ return findings
173
+ }
174
+
175
+ /**
176
+ * 安全检查(关键漏洞检测)
177
+ */
178
+ async checkSecurity(files, fileMap) {
179
+ const findings = []
180
+ const securityPatterns = {
181
+ // 硬编码密钥
182
+ 'password': { severity: 'ERROR', message: 'Potential hardcoded password' },
183
+ 'secret': { severity: 'ERROR', message: 'Potential hardcoded secret' },
184
+ 'api_key': { severity: 'ERROR', message: 'Potential API key' },
185
+ 'token': { severity: 'WARNING', message: 'Potential token hardcoded' },
186
+ // SQL 注入风险
187
+ 'SELECT .* FROM': { severity: 'ERROR', message: 'Possible SQL injection if string concatenation' },
188
+ 'eval(': { severity: 'CRITICAL', message: 'eval() is dangerous' },
189
+ // 路径遍历
190
+ '../': { severity: 'WARNING', message: 'Path traversal risk' },
191
+ '__dirname': { severity: 'WARNING', message: 'Check path construction safety' },
192
+ // XSS
193
+ 'innerHTML': { severity: 'WARNING', message: 'Potential XSS when using innerHTML' },
194
+ 'document.write': { severity: 'ERROR', message: 'Avoid document.write' }
195
+ }
196
+
197
+ for (const [filePath, hunks] of fileMap.entries()) {
198
+ const isCodeFile = this.isCodeFile(filePath)
199
+ if (!isCodeFile) continue
200
+
201
+ for (const hunk of hunks) {
202
+ for (const line of hunk.lines) {
203
+ if (line.type !== '+') continue
204
+
205
+ const content = line.content.toLowerCase()
206
+ for (const [pattern, info] of Object.entries(securityPatterns)) {
207
+ const regex = new RegExp(pattern, 'i')
208
+ if (regex.test(content)) {
209
+ findings.push({
210
+ type: 'security',
211
+ severity: info.severity,
212
+ file: filePath,
213
+ line: line.newLineNum,
214
+ message: info.message,
215
+ snippet: line.content.trim(),
216
+ rule: pattern
217
+ })
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ return findings
225
+ }
226
+
227
+ /**
228
+ * 测试覆盖率检查
229
+ */
230
+ async checkTests(files, fileMap) {
231
+ const findings = []
232
+
233
+ // 检查是否有新增代码但缺少对应测试
234
+ const modifiedFiles = new Set()
235
+ const testFiles = new Set()
236
+
237
+ for (const [filePath] of fileMap.entries()) {
238
+ modifiedFiles.add(filePath)
239
+ if (this.isTestFile(filePath)) {
240
+ testFiles.add(filePath)
241
+ }
242
+ }
243
+
244
+ // 统计未覆盖的修改文件
245
+ for (const file of modifiedFiles) {
246
+ if (this.isTestFile(file)) continue
247
+
248
+ const hasCorrespondingTest = this.findTestFile(file, Array.from(testFiles))
249
+ if (!hasCorrespondingTest) {
250
+ const changes = countFileChanges(fileMap.get(file) || [])
251
+ if (changes.additions > 5) { // 只提示较明显的修改
252
+ findings.push({
253
+ type: 'tests',
254
+ severity: 'WARNING',
255
+ file,
256
+ line: null,
257
+ message: `New/modified code but missing test file for ${this.getFileName(file)}`,
258
+ rule: 'missing-test'
259
+ })
260
+ }
261
+ }
262
+ }
263
+
264
+ return findings
265
+ }
266
+
267
+ /**
268
+ * 文档检查
269
+ */
270
+ async checkDocs(pr, files, fileMap) {
271
+ const findings = []
272
+
273
+ // 检查 PR 描述是否足够详细
274
+ if (!pr.body || pr.body.trim().length < 50) {
275
+ findings.push({
276
+ type: 'docs',
277
+ severity: 'INFO',
278
+ file: null,
279
+ line: null,
280
+ message: 'PR description is brief; consider adding more context (purpose, testing steps, breaking changes)',
281
+ rule: 'pr-description'
282
+ })
283
+ }
284
+
285
+ // 检查 API 变更是否更新文档
286
+ const hasAPIFiles = Array.from(fileMap.keys()).some(p => p.includes('src/') || p.includes('lib/'))
287
+ const hasDocsFiles = Array.from(fileMap.keys()).some(p => p.includes('docs/') || p.includes('README'))
288
+
289
+ if (hasAPIFiles && !hasDocsFiles) {
290
+ findings.push({
291
+ type: 'docs',
292
+ severity: 'WARNING',
293
+ file: null,
294
+ line: null,
295
+ message: 'Code changes detected but no documentation updates found',
296
+ rule: 'missing-docs'
297
+ })
298
+ }
299
+
300
+ return findings
301
+ }
302
+
303
+ /**
304
+ * 复杂度检查(简易版:检查大函数)
305
+ */
306
+ async checkComplexity(files, fileMap) {
307
+ const findings = []
308
+ const complexityThreshold = 50 // 行数阈值
309
+
310
+ for (const [filePath, hunks] of fileMap.entries()) {
311
+ const isCodeFile = this.isCodeFile(filePath)
312
+ if (!isCodeFile) continue
313
+
314
+ // 简单统计:统计新增的连续行
315
+ for (const hunk of hunks) {
316
+ let addedBlockStart = null
317
+ let addedBlockLines = 0
318
+
319
+ for (const line of hunk.lines) {
320
+ if (line.type === '+') {
321
+ if (addedBlockStart === null) {
322
+ addedBlockStart = line.newLineNum
323
+ }
324
+ addedBlockLines++
325
+ } else {
326
+ if (addedBlockLines > 0 && addedBlockLines >= complexityThreshold) {
327
+ findings.push({
328
+ type: 'complexity',
329
+ severity: 'WARNING',
330
+ file: filePath,
331
+ line: addedBlockStart,
332
+ message: `Large addition (${addedBlockLines} lines) may indicate complex logic`,
333
+ rule: 'large-block'
334
+ })
335
+ }
336
+ addedBlockStart = null
337
+ addedBlockLines = 0
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ return findings
344
+ }
345
+
346
+ /**
347
+ * 重复代码检查(简易版:检测重复行)
348
+ */
349
+ async checkDuplication(files, fileMap) {
350
+ const findings = []
351
+ const addedLines = []
352
+
353
+ // 收集所有新增行
354
+ for (const [filePath, hunks] of fileMap.entries()) {
355
+ const isCodeFile = this.isCodeFile(filePath)
356
+ if (!isCodeFile) continue
357
+
358
+ for (const hunk of hunks) {
359
+ for (const line of hunk.lines) {
360
+ if (line.type === '+') {
361
+ addedLines.push({
362
+ file: filePath,
363
+ line: line.newLineNum,
364
+ content: line.content.trim()
365
+ })
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ // 检查重复行(简单 O(n^2) 检查,适合增量数量)
372
+ const duplicates = new Set()
373
+ for (let i = 0; i < addedLines.length; i++) {
374
+ for (let j = i + 1; j < addedLines.length; j++) {
375
+ if (addedLines[i].content === addedLines[j].content &&
376
+ addedLines[i].content.length > 20 && // 忽略短行
377
+ !duplicates.has(i) && !duplicates.has(j)) {
378
+ findings.push({
379
+ type: 'duplication',
380
+ severity: 'WARNING',
381
+ file: addedLines[i].file,
382
+ line: addedLines[i].line,
383
+ message: 'Potential duplicate code found in another location',
384
+ rule: 'duplicate-lines',
385
+ duplicateLocation: `${addedLines[j].file}:${addedLines[j].line}`
386
+ })
387
+ duplicates.add(i)
388
+ duplicates.add(j)
389
+ }
390
+ }
391
+ }
392
+
393
+ return findings
394
+ }
395
+
396
+ // ============ LLM 智能分析 ============
397
+
398
+ /**
399
+ * 使用 LLM 进行整体评估
400
+ */
401
+ async llmAnalysis(pr, findings, diff) {
402
+ try {
403
+ const prompt = buildPRReviewPrompt(pr, diff, findings)
404
+ const response = await callLLM(
405
+ 'You are an expert code reviewer. Provide concise, actionable feedback.',
406
+ prompt,
407
+ this.apiConfig
408
+ )
409
+
410
+ // 解析 LLM 输出(简化:直接返回文本)
411
+ return {
412
+ summary: response,
413
+ riskLevel: this._inferRiskLevel(response),
414
+ recommendations: this._extractRecommendations(response)
415
+ }
416
+ } catch (error) {
417
+ console.error('LLM analysis failed:', error)
418
+ // 降级到规则统计
419
+ return this._fallbackAnalysis(findings)
420
+ }
421
+ }
422
+
423
+ _inferRiskLevel(text) {
424
+ const lower = text.toLowerCase()
425
+ if (lower.includes('reject') || lower.includes('critical')) return 'HIGH'
426
+ if (lower.includes('needs work') || lower.includes('issues')) return 'MEDIUM'
427
+ return 'LOW'
428
+ }
429
+
430
+ _extractRecommendations(text) {
431
+ const lines = text.split('\n')
432
+ const recs = []
433
+ for (const line of lines) {
434
+ if (line.match(/^\s*[-*•] /i) || line.includes('Recommendation') || line.includes('建议')) {
435
+ recs.push(line.trim())
436
+ }
437
+ }
438
+ return recs.length > 0 ? recs : ['See LLM analysis for details']
439
+ }
440
+
441
+ _fallbackAnalysis(findings) {
442
+ const severityCount = { CRITICAL: 0, ERROR: 0, WARNING: 0, INFO: 0 }
443
+ for (const f of findings) {
444
+ severityCount[f.severity] = (severityCount[f.severity] || 0) + 1
445
+ }
446
+ const total = findings.length
447
+ const riskLevel = total > 20 ? 'HIGH' : total > 10 ? 'MEDIUM' : 'LOW'
448
+ return {
449
+ summary: `PR 审查完成:发现 ${total} 个问题(CRITICAL: ${severityCount.CRITICAL}, ERROR: ${severityCount.ERROR}, WARNING: ${severityCount.WARNING}, INFO: ${severityCount.INFO})`,
450
+ riskLevel,
451
+ recommendations: total > 0
452
+ ? ['建议修复 CRITICAL 和 ERROR 级别问题', '注意安全问题', '补充测试用例']
453
+ : ['PR 质量良好,可以合并']
454
+ }
455
+ }
456
+
457
+ // ============ 辅助方法 ============
458
+
459
+ shouldComment(finding, threshold) {
460
+ const severityOrder = { CRITICAL: 4, ERROR: 3, WARNING: 2, INFO: 1 }
461
+ const thresholdOrder = { ERROR: 3, WARNING: 2, INFO: 1 }
462
+ return severityOrder[finding.severity] >= thresholdOrder[threshold]
463
+ }
464
+
465
+ buildReviewComment(finding) {
466
+ const emoji = this.getSeverityEmoji(finding.severity)
467
+ const body = `${emoji} **${finding.severity}** - ${finding.message}`
468
+
469
+ return {
470
+ body,
471
+ path: finding.file,
472
+ line: finding.line, // 保留原始行号,外层调用方决定是否转 position
473
+ }
474
+ }
475
+
476
+ getSeverityEmoji(severity) {
477
+ switch (severity) {
478
+ case 'CRITICAL': return '🔴'
479
+ case 'ERROR': return '🚫'
480
+ case 'WARNING': return '⚠️'
481
+ case 'INFO': return 'ℹ️'
482
+ default: return '📝'
483
+ }
484
+ }
485
+
486
+ generateReviewReport(pr, findings, llmSummary) {
487
+ const severityCount = { CRITICAL: 0, ERROR: 0, WARNING: 0, INFO: 0 }
488
+ for (const f of findings) {
489
+ severityCount[f.severity] = (severityCount[f.severity] || 0) + 1
490
+ }
491
+
492
+ const total = findings.length
493
+ const blockers = severityCount.CRITICAL + severityCount.ERROR
494
+
495
+ return {
496
+ overall: llmSummary?.riskLevel || (blockers > 0 ? 'NEEDS_WORK' : 'CLEAN'),
497
+ totalFindings: total,
498
+ severityCount,
499
+ blockers,
500
+ summary: llmSummary?.summary || `Review completed: ${total} issues found (${blockers} blocking)`,
501
+ recommendations: total > 0
502
+ ? ['Fix CRITICAL/ERROR issues before merging', 'Address WARNING items', 'Consider INFO suggestions']
503
+ : ['PR looks good to merge']
504
+ }
505
+ }
506
+
507
+ isCodeFile(filePath) {
508
+ const codeExt = ['.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go', '.rs', '.c', '.cpp', '.h', '.hpp']
509
+ return codeExt.some(ext => filePath.endsWith(ext))
510
+ }
511
+
512
+ isTestFile(filePath) {
513
+ const testPatterns = ['test', 'spec', '__tests__', '.test.', '.spec.']
514
+ return testPatterns.some(p => filePath.includes(p))
515
+ }
516
+
517
+ getFileName(filePath) {
518
+ return filePath.split('/').pop()
519
+ }
520
+
521
+ findTestFile(sourceFile, testFiles) {
522
+ const name = this.getFileName(sourceFile)
523
+ // 简单的命名约定:xxx.js → xxx.test.js / xxx.spec.js / test/xxx.test.js
524
+ const patterns = [
525
+ `test/${name}.test.js`,
526
+ `test/${name}.spec.js`,
527
+ `${name}.test.js`,
528
+ `${name}.spec.js`,
529
+ `__tests__/${name}.js`
530
+ ]
531
+ return patterns.some(p => testFiles.includes(p))
532
+ }
533
+ }