refresh review pipeline
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 19s
CI / 3. RESULT (pull_request) Skipped

This commit is contained in:
2026-08-07 06:05:58 +00:00
parent e1d7f59a38
commit 0eb30cf9d4
22 changed files with 2311 additions and 1620 deletions
+125 -31
View File
@@ -8,8 +8,14 @@ import { line, ok, warn } from './log.js';
const LEVELS = ['critical', 'warning', 'info'];
/**
* 用單一角色分析 diff,回傳 findings 陣列。
* role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。
* 用單一角色分析 diff,呼叫 LLM 取得該角色視角下的 code review 問題並回傳 findings 陣列。
* role 欄位一律以角色定義的 name 為準(覆寫 LLM 回傳值),避免 LLM 自行填入不一致的角色名稱。
*
* @param {{name: string}} role - 審查角色定義物件,至少需含 name。
* @param {string} diff - 欲分析的 unified diff 文字內容。
* @returns {Promise<Array<object>>} 有效 findings 陣列(僅保留同時具備 level/location/suggestion 者),
* 每筆皆補上 role(角色名稱)與 is_new: true。
* @throws 當 chatJSON 呼叫失敗(LLM 錯誤、額度限制等)時直接拋出例外,本函式不做降級處理。
*/
export async function analyzeWithRole(role, diff) {
line(`[${role.name}] 開始分析`);
@@ -21,7 +27,11 @@ export async function analyzeWithRole(role, diff) {
}
/**
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列
* 讀取 JSON 陣列檔案;檔案不存在、讀取失敗或內容非陣列時,皆視為空並回傳 []。
*
* @param {string} fullPath - 欲讀取的 JSON 檔案完整路徑。
* @param {string} label - 用於警告訊息中識別此次讀取對象的標籤文字(例如「舊 findings 」)。
* @returns {Array<object>} 解析出的陣列;任何失敗情況皆回傳空陣列 []。
*/
function readJSONArray(fullPath, label) {
if (!fs.existsSync(fullPath)) {
@@ -101,21 +111,18 @@ function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
/**
* 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。
*
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
* 因為比對常對同一段文字重複呼叫(findings × exclusions 笛卡爾積),
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。
*/
const _normalizeTextCache = new Map();
/**
* 將文字正規化成比對用形式。
* 將文字正規化為比對用形式:先以 cleanText 轉為安全字串,NFKC 正規化、轉小寫,
* 並把所有標點/符號/空白字元壓縮成單一空白(再壓縮連續空白、去頭尾空白)。
* 因常對同一段文字重複呼叫(findings × exclusions 笛卡爾積比對),
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複執行 NFKC/正則運算。
*
* @param {*} value - 任意值。
* @remarks 適合用於誤報過濾與排除條目比對。
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串(不會寫入快取)。
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
* 快取為模組層級、程序生命週期內不會清除,需人工確認長期執行(如常駐服務)情境下是否有記憶體成長風險;
* 在本專案作為一次性 CI 腳本執行的用法下應無實際影響。
*/
export function normalizeText(value) {
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
@@ -297,7 +304,12 @@ function buildExclusionContext(exclusions) {
}
/**
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
* 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案),
* 每筆項目一律標記 is_new: false(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
* 檔案不存在或讀取失敗時視為空陣列,不拋例外。
*
* @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。
* @returns {Array<object>} 舊 findings 陣列,每筆皆含 is_new: false;讀取失敗或檔案不存在時回傳空陣列。
*/
export function loadOldFindings(workspace) {
const fullPath = path.join(workspace, FINDINGS_PATH);
@@ -314,7 +326,13 @@ export function loadOldFindings(workspace) {
}
/**
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
* 合併新舊 findings:以 (role + location + suggestion 前 50 字) 組成的字串為 key,
* 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。
* oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。
*
* @param {Array<object>} oldFindings - 既有(上一輪)findings 陣列,作為去重比對基準,原樣保留於結果前段。
* @param {Array<object>} newFindings - 本輪新產生的 findings 陣列,將依 key 去除與 oldFindings 重複者。
* @returns {Array<object>} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。
*/
export function mergeFindings(oldFindings, newFindings) {
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
@@ -330,14 +348,25 @@ export function mergeFindings(oldFindings, newFindings) {
}
/**
* 依等級排序(critical > warning > info)
* 依等級排序(critical > warning > info),回傳新陣列,不修改傳入的 findings。
*
* @param {Array<object>} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列。
* @remarks level 不在 ['critical','warning','info'] 中的項目,因 indexOf 回傳 -1,
* 會被排到 critical 之前(最前面)而非最後面;此邊界行為是否為預期設計,需人工確認。
*/
export function sortByLevel(findings) {
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
}
/**
* AI 呼叫失敗時的統一降級處理
* AI 呼叫失敗時的統一降級處理:記錄警告訊息後原樣回傳 findings(不做任何篩選),
* 確保 AI(去重/誤報過濾等)暫時性失敗時不會誤刪合法問題。
*
* @param {string} label - 用於警告訊息中識別此次失敗的處理名稱(例如「AI 去重」)。
* @param {Array<object>} findings - 發生失敗前的 findings 陣列,將原樣回傳。
* @param {Error} e - 捕捉到的錯誤物件;若 e.response.status 為 402 或 429,訊息會顯示為「額度/限流」,否則顯示 e.message。
* @returns {Array<object>} 原樣回傳的 findings(與傳入的參照相同,未複製)。
*/
function fallback(label, findings, e) {
const status = e.response?.status;
@@ -348,7 +377,13 @@ function fallback(label, findings, e) {
const MAX_LOCATE_ATTEMPTS = 3;
/** 從 location 取出行號;無 `檔案:行號`(或多檔逗號)時回 null。 */
/**
* 從 location(格式如「檔案:行號」或「檔案:起始行-結束行」)取出行號。
*
* @param {string|null|undefined} location - finding 的 location 欄位。
* @returns {number|null} 解析出的(起始)行號;若 location 為空、包含逗號(代表多檔案)
* 或不符合「檔案:數字」格式,回傳 null。範圍格式僅回傳起始行號,不回傳結束行號。
*/
function findingLine(location) {
const s = String(location || '').trim();
if (!s || s.includes(',')) return null;
@@ -356,7 +391,14 @@ function findingLine(location) {
return m ? Number(m[2]) : null;
}
/** 從整份 unified diff 擷取指定檔案的區段,找不到時回退整份 diff。 */
/**
* 從整份 unified diff 擷取指定檔案的區段(依 `diff --git a/... b/...` 標頭切分);找不到對應區段時回退回傳整份 diff。
*
* @param {string} diff - 完整的 unified diff 文字。
* @param {string} file - 欲擷取的檔案路徑(會以 includes 比對是否出現在 diff --git 標頭的 a/、b/ 路徑中)。
* @returns {string} 該檔案對應的 diff 區段文字;若無法定位,回退回傳原始 diff 字串。
* @remarks 檔名比對採子字串 includes,若 file 恰為另一檔案路徑的子字串,可能誤判擷取到錯誤區段,此為已知限制,需人工確認是否需要更嚴謹的邊界比對。
*/
function extractFileDiff(diff, file) {
const lines = String(diff || '').split('\n');
const out = [];
@@ -371,7 +413,14 @@ function extractFileDiff(diff, file) {
/**
* 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號,
* 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈);
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
* 成功則直接修改(mutate)該 finding 的 location 為 `檔案:行號`,否則保留原檔名不變。
* 各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 concurrency。
*
* @param {Array<object>} findings - findings 陣列;缺行號且有檔名者會被就地修改 location(mutate),其餘不受影響。
* @param {string} diff - 完整 unified diff,用於擷取各檔案對應區段作為定位依據。
* @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps] - 依賴注入(利於測試):
* chatFn 預設 chatJSON;getRole 預設 loadRole;maxAttempts 預設 3(MAX_LOCATE_ATTEMPTS);concurrency 預設 LLM_CONCURRENCY。
* @returns {Promise<Array<object>>} 與傳入 findings 相同參照的陣列(部分項目的 location 已被就地修改)。
*/
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
@@ -418,7 +467,13 @@ function toAIPayload(findings) {
}
/**
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
* 呼叫 LLM(Paladin 角色)進行語意去重:合併「同位置+同問題本質」的重複 findings,重複者保留等級較高者。
* 為避免 LLM 幻覺出不存在的內容,回傳結果會逐筆以 (location + suggestion 前 50 字) 對應回原始 findings,
* 對應不到、結果為空、非陣列或數量超過輸入筆數者,皆視為異常並整批降級為保留所有原始 findings(不篩選)。
*
* @param {Array<object>} findings - 欲去重的 findings 陣列;為空陣列時直接原樣回傳。
* @returns {Promise<Array<object>>} 去重後的原始 finding 物件陣列(非 LLM 回傳的精簡版);
* AI 呼叫失敗或結果驗證異常時,降級回傳原始 findings(未經任何篩選)。
*/
export async function deduplicateWithAI(findings) {
if (findings.length === 0) return findings;
@@ -445,7 +500,15 @@ export async function deduplicateWithAI(findings) {
}
/**
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
* 讀取排除問題檔案(來源分支 cloned repoDir 下 EXCLUSIONS_PATH),正規化並去重後回傳。
* 若偵測到檔案為舊格式(非頂層陣列,如 { exclusions: [...] } 或 { excluded_findings: [...] }),
* 會就地把該檔案覆寫為標準頂層陣列格式(若提供 mirrorWorkspace 且路徑不同,也會同步寫入 mirror 目錄)。
* 檔案不存在或讀取/解析失敗時,皆視為空陣列,不拋出例外。
*
* @param {string} workspace - 來源分支工作目錄根路徑,EXCLUSIONS_PATH 會相對此路徑解析。
* @param {object|null} [repoState] - 可選的來源分支狀態(branch/shortSha 或 headSha/commitTime),僅用於診斷日誌。
* @param {string|null} [mirrorWorkspace] - 可選的鏡像工作目錄;當原始格式非頂層陣列時,會同步覆寫此目錄下的 exclusions.json。
* @returns {Array<object>} 正規化並去重後的排除條目陣列;讀取失敗或檔案不存在時回傳空陣列。
*/
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
@@ -494,8 +557,15 @@ export function loadExclusions(workspace, repoState = null, mirrorWorkspace = nu
}
/**
* 把新的排除條目(raw 形式)append 到 exclusions.json,去重後以頂層陣列寫回 workspace 與 mirror。
* 去重以「檔案路徑 + 正規化原文」為準。回傳合併後的 raw 陣列(無新增時回傳既有陣列)。
* 把新的排除條目(raw 形式,未經 normalizeExclusionEntry 加工)append 到 exclusions.json,
* 以「檔案路徑(location 冒號前段)+ normalizeText 後的原文」為簽名去重後,
* 以頂層陣列格式寫回 workspace(及提供且路徑不同的 mirrorWorkspace)。
*
* @param {string} workspace - 目標工作目錄,EXCLUSIONS_PATH 相對此路徑解析並寫入。
* @param {Array<object>} newEntries - 欲新增的排除條目(raw 形式);為空或未提供時直接回傳 null(無操作)。
* @param {string|null} [mirrorWorkspace] - 可選鏡像目錄;提供且與 workspace 路徑不同時,會同步寫入相同內容。
* @returns {Array<object>|null} 合併後的 raw 排除條目陣列;newEntries 為空時回傳 null;
* 若 newEntries 皆與既有條目重複(無實際新增)則回傳既有陣列(未寫檔)。
*/
export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) {
if (!newEntries || newEntries.length === 0) return null;
@@ -538,8 +608,19 @@ export function appendExclusions(workspace, newEntries, mirrorWorkspace = null)
}
/**
* 套用排除規則,過濾掉符合排除條件的 findings
* location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用
* 套用排除規則,過濾掉符合任一排除條件的 findings。
* exclusions 為空時原樣回傳 findings(新陣列,不修改原輸入)。
*
* 比對規則(對每個 exclusion,locationMatches && roleMatches && (有指定 path 或 role ? 一律視為符合 : textMatches)):
* - location 只比對檔案路徑(忽略行號),exclusion 未指定 filePath 時視為萬用;
* - role 未指定時視為萬用,否則需與 finding.role 完全相等;
* - 僅當 exclusion 同時未指定 filePath 與 role 時,才會實際比對正規化後文字(suggestion/title 等)是否互相包含。
*
* @param {Array<object>} findings - 欲過濾的 findings 陣列。
* @param {Array<object>} exclusions - 排除條目陣列(建議為已正規化含 filePath 的條目)。
* @returns {Array<object>} 過濾後的新陣列。
* @remarks 「只要 exclusion 指定了 filePath 或 role,文字比對即完全略過」是否為刻意設計,需人工確認;
* 若非刻意,可能造成排除範圍比預期寬(例如同檔案下所有問題都被排除,而非僅特定描述的問題)。
*/
export function applyExclusions(findings, exclusions) {
if (exclusions.length === 0) return findings;
@@ -558,7 +639,15 @@ export function applyExclusions(findings, exclusions) {
return filtered;
}
/** 派一個「防守方」sub-agent 裁決單一 finding 是否為誤報;任何失敗都保守視為成立(保留)。 */
/**
* 派一個「防守方」角色裁決單一 finding 是否為誤報;任何失敗都保守視為「成立」(即保留該問題)。
*
* @param {object} finding - 欲裁決的單一 finding。
* @param {object} defender - 防守方角色定義(通常為 Paladin),供 buildVerdictPrompt 組系統提示。
* @param {string} exclusionHint - 已知誤報清單的提示文字(可為空字串),供 AI 判斷是否與已知誤報類似。
* @param {Function} chatFn - 實際呼叫 LLM 的函式(簽名同 chatJSON),供測試時注入替換。
* @returns {Promise<boolean>} true 表示裁決為誤報(應剔除);false 表示成立或裁決失敗(保守保留)。
*/
async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) {
const systemPrompt = buildVerdictPrompt(defender, exclusionHint);
try {
@@ -571,8 +660,13 @@ async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, cha
}
/**
* 由「防守方」角色(Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
* 多個問題時各派一個 sub-agent 平行裁決;任一裁決失敗保守保留該問題,不中斷流程。
* 由「防守方」角色(固定為 Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
* 多個問題時各派一個裁決任務平行處理(併發上限 LLM_CONCURRENCY);任一裁決失敗保守保留該問題,不中斷流程。
*
* @param {Array<object>} findings - 欲裁決的 findings 陣列;為空陣列時直接原樣回傳。
* @param {Array<object>} [exclusions=[]] - 已知誤報排除條目,用於組裝提示,引導 AI 對相似的誤報更寬鬆判定。
* @param {Function} [chatFn=chatJSON] - 實際呼叫 LLM 的函式,供測試時注入替換。
* @returns {Promise<Array<object>>} 裁決為「非誤報」而保留下來的原始 finding 物件陣列。
*/
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
if (findings.length === 0) return findings;