161 lines
6.2 KiB
JavaScript
161 lines
6.2 KiB
JavaScript
import fs from 'fs';
|
||
import path from 'path';
|
||
import { chatJSON } from './llm.js';
|
||
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||
|
||
const LEVELS = ['critical', 'warning', 'info'];
|
||
|
||
/**
|
||
* 用單一角色分析 diff,回傳 findings 陣列
|
||
*/
|
||
export async function analyzeWithRole(role, diff) {
|
||
console.log(` [${role.name}] 開始分析...`);
|
||
const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`);
|
||
const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion)
|
||
.map(f => ({ ...f, is_new: true }));
|
||
console.log(` [${role.name}] 找到 ${valid.length} 個問題`);
|
||
return valid;
|
||
}
|
||
|
||
/**
|
||
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH)
|
||
*/
|
||
export function loadOldFindings(workspace) {
|
||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||
if (!fs.existsSync(fullPath)) {
|
||
console.log(' 舊 findings 檔案不存在,視為空');
|
||
return [];
|
||
}
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||
const old = (Array.isArray(data) ? data : []).map(f => ({ ...f, is_new: false }));
|
||
console.log(` 讀取舊 findings: ${old.length} 筆`);
|
||
return old;
|
||
} catch (e) {
|
||
console.log(` ⚠️ 讀取舊 findings 失敗: ${e.message},視為空`);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
|
||
*/
|
||
export function mergeFindings(oldFindings, newFindings) {
|
||
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
||
const seen = new Set(oldFindings.map(key));
|
||
const deduped = newFindings.filter(f => {
|
||
if (seen.has(key(f))) return false;
|
||
seen.add(key(f));
|
||
return true;
|
||
});
|
||
const merged = [...oldFindings, ...deduped];
|
||
console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* 依等級排序(critical > warning > info)
|
||
*/
|
||
export function sortByLevel(findings) {
|
||
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
|
||
}
|
||
|
||
/**
|
||
* AI 呼叫失敗時的統一降級處理
|
||
*/
|
||
function fallback(label, findings, e) {
|
||
const status = e.response?.status;
|
||
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
|
||
console.log(` ⚠️ ${label}失敗(${reason}),降級:保留所有問題`);
|
||
return findings;
|
||
}
|
||
|
||
/**
|
||
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
||
*/
|
||
export async function deduplicateWithAI(findings) {
|
||
if (findings.length === 0) return findings;
|
||
|
||
const systemPrompt = `你是一位程式碼審查問題去重專家。
|
||
給你一份問題清單(JSON 陣列),請移除語意重複的問題(即使描述文字不同,但指的是同一個問題)。
|
||
保留等級較高的版本,優先保留 critical > warning > info。
|
||
只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
||
|
||
try {
|
||
const result = await chatJSON(systemPrompt, `以下是問題清單,請去除語意重複的項目:\n\n${JSON.stringify(findings, null, 2)}`);
|
||
if (Array.isArray(result) && result.length > 0) {
|
||
console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`);
|
||
return result;
|
||
}
|
||
throw new Error('AI 回傳空陣列');
|
||
} catch (e) {
|
||
return fallback('AI 去重', findings, e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH)
|
||
*/
|
||
export function loadExclusions(workspace) {
|
||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||
if (!fs.existsSync(fullPath)) {
|
||
console.log(' 排除問題檔案不存在,跳過過濾');
|
||
return [];
|
||
}
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||
const exclusions = Array.isArray(data) ? data : [];
|
||
console.log(` 讀取排除問題: ${exclusions.length} 筆`);
|
||
return exclusions;
|
||
} catch (e) {
|
||
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},跳過過濾`);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 套用排除規則,過濾掉符合排除條件的 findings
|
||
* location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用
|
||
*/
|
||
export function applyExclusions(findings, exclusions) {
|
||
if (exclusions.length === 0) return findings;
|
||
const before = findings.length;
|
||
const filtered = findings.filter(f => !exclusions.some(ex => {
|
||
const fPath = String(f.location).split(':')[0];
|
||
const exPath = ex.location ? String(ex.location).split(':')[0] : null;
|
||
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role);
|
||
}));
|
||
console.log(` 排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
|
||
return filtered;
|
||
}
|
||
|
||
/**
|
||
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
|
||
*/
|
||
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
|
||
if (findings.length === 0) return findings;
|
||
|
||
const exclusionHint = exclusions.length > 0
|
||
? `\n\n以下是已知的誤報或不需處理的問題清單(供參考,相同檔案路徑且語意相近的問題應一併排除):\n${JSON.stringify(exclusions, null, 2)}`
|
||
: '';
|
||
|
||
const systemPrompt = `你是一位資深程式碼審查專家,負責判斷審查問題是否為誤報或不需處理。
|
||
給你一份問題清單(JSON 陣列),每筆包含 level、role、location、suggestion。
|
||
請移除以下類型的問題:
|
||
1. 誤報:問題描述與實際程式碼不符(例如:程式碼已正確使用環境變數或 secrets,卻被標記為硬編碼敏感資料)
|
||
2. 不適用:問題在此專案情境下不需處理(例如:CI/CD action 本來就需要透過環境變數傳遞 token)
|
||
3. 與已知誤報清單語意相近的問題(檔案路徑相同且建議內容相似)
|
||
只回傳需要保留的問題 JSON 陣列,不要有其他文字。${exclusionHint}`;
|
||
|
||
try {
|
||
const result = await chatJSON(systemPrompt, `請判斷以下問題清單,移除誤報或不需處理的問題:\n\n${JSON.stringify(findings, null, 2)}`);
|
||
if (Array.isArray(result) && result.length > 0) {
|
||
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`);
|
||
return result;
|
||
}
|
||
throw new Error('AI 回傳空陣列或非陣列');
|
||
} catch (e) {
|
||
return fallback('AI 誤報過濾', findings, e);
|
||
}
|
||
}
|