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; } /** * 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列 */ function readJSONArray(fullPath, label) { if (!fs.existsSync(fullPath)) { console.log(` ${label}檔案不存在,視為空`); return []; } try { const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); return Array.isArray(data) ? data : []; } catch (e) { console.log(` ⚠️ 讀取${label}失敗: ${e.message},視為空`); return []; } } /** * 讀取舊 findings(從 workspace 的 FINDINGS_PATH) */ export function loadOldFindings(workspace) { const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false })); console.log(` 讀取舊 findings: ${old.length} 筆`); return old; } /** * 合併新舊 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; } /** 只保留 AI 需要的欄位,減少 token 用量 */ export function toAIPayload(findings) { return findings.map(({ level, role, location, suggestion }) => ({ level, role, location, suggestion })); } /** * 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings */ export async function deduplicateWithAI(findings, chatFn = chatJSON) { if (findings.length === 0) return findings; const systemPrompt = `移除語意重複的程式碼審查問題(JSON 陣列)。保留等級較高者(critical > warning > info)。只回傳去重後的 JSON 陣列。`; try { const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings))); if (Array.isArray(result) && result.length > 0) { console.log(` AI 去重: ${findings.length} -> ${result.length} 筆`); // 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回 const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); } throw new Error('AI 回傳空陣列'); } catch (e) { return fallback('AI 去重', findings, e); } } /** * 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH) */ export function loadExclusions(workspace) { const exclusions = readJSONArray(path.join(workspace, EXCLUSIONS_PATH), '排除問題'); console.log(` 讀取排除問題: ${exclusions.length} 筆`); return exclusions; } /** * 套用排除規則,過濾掉符合排除條件的 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 = [], chatFn = chatJSON) { if (findings.length === 0) return findings; const exclusionHint = exclusions.length > 0 ? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}` : ''; const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`; try { const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings))); if (Array.isArray(result) && result.length > 0) { console.log(` AI 誤報過濾: ${findings.length} -> ${result.length} 筆`); const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); } throw new Error('AI 回傳空陣列或非陣列'); } catch (e) { return fallback('AI 誤報過濾', findings, e); } }