Compare commits

...
Author SHA1 Message Date
Jeffery 11205d5b82 chore(workflows): CI 目標非 develop 跳過 test job、CD 加印 gitea context 與 fetch-tags
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 12m14s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 17:11:21 +08:00
AI Review Bot 0be25e667c chore: update ai-review findings [ai-review-bot][failure]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 28s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 09:04:44 +00:00
Jeffery 93142e2250 test(llm): 補 mapWithConcurrency 測試
CI / 1. BUILD (pull_request) Successful in 1s
CI / 2. TEST (pull_request) Failing after 11m17s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 16:53:24 +08:00
Jeffery c521451b66 perf(LLM 併發): 角色分析與誤報/補行號裁決改為並行 sub-agent(預設不限併發) 2026-07-03 16:53:24 +08:00
7 changed files with 196 additions and 70 deletions
+88 -48
View File
@@ -21,7 +21,7 @@
"location": "src/gitea.js:66", "location": "src/gitea.js:66",
"problem": "這裡直接從 PR head 讀取 `.reviewignore`,再拿它當成排除規則。攻擊者可以在自己的分支塞入排除條目,讓 bot 故意跳過包含惡意變更的檔案或整個目錄,等於自己決定哪些地方不被審查。", "problem": "這裡直接從 PR head 讀取 `.reviewignore`,再拿它當成排除規則。攻擊者可以在自己的分支塞入排除條目,讓 bot 故意跳過包含惡意變更的檔案或整個目錄,等於自己決定哪些地方不被審查。",
"suggestion": "不要信任 PR head 裡的 `.reviewignore` 來決定安全掃描範圍;改從受保護的 base branch 或 maintainer 管控的位置讀取,且要與固定的預設排除清單合併,而不是讓它覆蓋預設規則。", "suggestion": "不要信任 PR head 裡的 `.reviewignore` 來決定安全掃描範圍;改從受保護的 base branch 或 maintainer 管控的位置讀取,且要與固定的預設排除清單合併,而不是讓它覆蓋預設規則。",
"is_new": true "is_new": false
}, },
{ {
"level": "critical", "level": "critical",
@@ -29,6 +29,14 @@
"location": "src/findings.js:352", "location": "src/findings.js:352",
"problem": "這裡會直接讀取 PR 工作樹中的 `.gitea/ai-review/exclusions.json` 當成可信排除來源。攻擊者可以先在分支裡放一份藏在 `.gitea/` 下的 exclusions 檔,利用被忽略的路徑把自己的問題先排除掉,讓後續的 findings 被靜默吃掉。", "problem": "這裡會直接讀取 PR 工作樹中的 `.gitea/ai-review/exclusions.json` 當成可信排除來源。攻擊者可以先在分支裡放一份藏在 `.gitea/` 下的 exclusions 檔,利用被忽略的路徑把自己的問題先排除掉,讓後續的 findings 被靜默吃掉。",
"suggestion": "把 exclusions 視為 bot 自己管理的狀態,不要從 PR head 的工作樹直接信任既有內容;應該改成只讀受保護來源,或在載入前驗證檔案確實由 bot 生成且未被 PR 作者預先植入。", "suggestion": "把 exclusions 視為 bot 自己管理的狀態,不要從 PR head 的工作樹直接信任既有內容;應該改成只讀受保護來源,或在載入前驗證檔案確實由 bot 生成且未被 PR 作者預先植入。",
"is_new": false
},
{
"level": "critical",
"role": "Assassin",
"location": "src/main.js:158",
"problem": "這裡直接載入 PR 工作樹中的 `.gitea/ai-review/exclusions.json` 當成既有排除規則。攻擊者可以先在 PR 內預埋一份排除清單,因為 `.gitea/` 又被預設排除於 diff 之外,這些惡意排除不會被審查到,卻會被流程直接拿來吞掉真正的 findings,形成靜默的審查繞過。",
"suggestion": "不要從 PR head 讀取可由提交者任意修改的 exclusions;只接受由受信任 bot、受保護分支或外部持久化儲存產生的排除資料,並驗證來源身分與 commit marker,避免使用者自行預埋排除規則。",
"is_new": true "is_new": true
}, },
{ {
@@ -45,7 +53,7 @@
"location": "src/main.js:69", "location": "src/main.js:69",
"problem": "這裡先檢查 head SHA 對應的訊息是否為 failure,但如果 SHA 查詢失敗或是空值,後面的 `shouldSkipBotCommit()` 仍可能只看到分支 head 上的 `[ai-review-bot]` 標記就直接跳過。最小重現:`getCommitMessageBySha()` 因 Gitea API 暫時失敗回空字串,而分支 head 正好是 `[ai-review-bot][failure]`,流程就會 exit 0,等於把本來應該失敗的 bot commit 當成可跳過的自動提交。", "problem": "這裡先檢查 head SHA 對應的訊息是否為 failure,但如果 SHA 查詢失敗或是空值,後面的 `shouldSkipBotCommit()` 仍可能只看到分支 head 上的 `[ai-review-bot]` 標記就直接跳過。最小重現:`getCommitMessageBySha()` 因 Gitea API 暫時失敗回空字串,而分支 head 正好是 `[ai-review-bot][failure]`,流程就會 exit 0,等於把本來應該失敗的 bot commit 當成可跳過的自動提交。",
"suggestion": "把「是否跳過」和「是否 failure」分開判斷,或讓 helper 回傳解析出的 outcome;只允許 success 標記走 skipfailure 標記不論 SHA/branch 來源都應優先讓流程失敗。", "suggestion": "把「是否跳過」和「是否 failure」分開判斷,或讓 helper 回傳解析出的 outcome;只允許 success 標記走 skipfailure 標記不論 SHA/branch 來源都應優先讓流程失敗。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -109,14 +117,6 @@
"location": "src/findings.js:442", "location": "src/findings.js:442",
"problem": "這裡每一筆 finding 都要跟整包 exclusions 做一次 `.some()`,而且內層還反覆跑 `normalizeText` 和字串包含比對,資料一多就直接變成 O(F×E) 的熱點。像 300 筆 finding 配 500 筆 exclusions,會吃掉 15 萬次以上的比對與正規化,CPU 和字串配置都在浪費。", "problem": "這裡每一筆 finding 都要跟整包 exclusions 做一次 `.some()`,而且內層還反覆跑 `normalizeText` 和字串包含比對,資料一多就直接變成 O(F×E) 的熱點。像 300 筆 finding 配 500 筆 exclusions,會吃掉 15 萬次以上的比對與正規化,CPU 和字串配置都在浪費。",
"suggestion": "先把 exclusions 在載入時一次正規化並依 `filePath / role / textKey` 建索引,讓過濾改成近似 O(F);至少把 `normalizeText` 移到內層迴圈外,避免同一段字串被重算成百上千次。", "suggestion": "先把 exclusions 在載入時一次正規化並依 `filePath / role / textKey` 建索引,讓過濾改成近似 O(F);至少把 `normalizeText` 移到內層迴圈外,避免同一段字串被重算成百上千次。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "src/resolve.js:183",
"problem": "`reconcileConversations` 同時負責收 comment、分組、關閉、讀檔、AI 裁決、結果分類與降級處理,職責太多而且彼此耦合。任何一個小規則變動,都得先看完整條流程,單元測試也很難只鎖定某一段行為。",
"suggestion": "拆成幾個可測的純函式與薄編排層,例如 `groupConversations`、`closeOpenComments`、`buildJudgeItems`、`applyVerdicts` 分開處理,讓主流程只保留資料流轉與錯誤收斂。",
"is_new": false "is_new": false
}, },
{ {
@@ -141,7 +141,7 @@
"location": "src/comments.js:233", "location": "src/comments.js:233",
"problem": "`postFindingsReview` 的降級流程有兩層:先嘗試批次 review,再失敗時改成 summary-only,最後 summary-only 也失敗才退回一般 comment。現在的測試只驗到第一層失敗後、第二層成功的情境,沒有驗證 summary-only 也失敗時是否真的會呼叫 `postIssue(body)`,這是最脆弱的 fallback 路徑之一。", "problem": "`postFindingsReview` 的降級流程有兩層:先嘗試批次 review,再失敗時改成 summary-only,最後 summary-only 也失敗才退回一般 comment。現在的測試只驗到第一層失敗後、第二層成功的情境,沒有驗證 summary-only 也失敗時是否真的會呼叫 `postIssue(body)`,這是最脆弱的 fallback 路徑之一。",
"suggestion": "新增一個測試讓第一次 `postReview({comments})` 失敗、第二次 `postReview({comments: []})` 也失敗,然後斷言 `postIssue(body)` 有被呼叫,且 inline comments 仍會逐筆嘗試送出。", "suggestion": "新增一個測試讓第一次 `postReview({comments})` 失敗、第二次 `postReview({comments: []})` 也失敗,然後斷言 `postIssue(body)` 有被呼叫,且 inline comments 仍會逐筆嘗試送出。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -157,7 +157,7 @@
"location": "src/config.js:27", "location": "src/config.js:27",
"problem": "這段註解已經跟著介面走音了。它宣稱使用端「只需傳 `with: token`」,但這次 action 其實已新增 `comment_token` 與 `model` 等輸入,註解仍停留在舊旋律,容易讓讀者誤判介面現況。", "problem": "這段註解已經跟著介面走音了。它宣稱使用端「只需傳 `with: token`」,但這次 action 其實已新增 `comment_token` 與 `model` 等輸入,註解仍停留在舊旋律,容易讓讀者誤判介面現況。",
"suggestion": "把這組說明改成與目前 inputs 一致,明確列出 `token`、`comment_token`、`model` 的優先序與用途;如果無法精簡,就直接移到 README 或設計文件,避免在程式中留下過時註記。", "suggestion": "把這組說明改成與目前 inputs 一致,明確列出 `token`、`comment_token`、`model` 的優先序與用途;如果無法精簡,就直接移到 README 或設計文件,避免在程式中留下過時註記。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -165,7 +165,7 @@
"location": "src/findings.js:227", "location": "src/findings.js:227",
"problem": "這個抽取器一旦命中目標檔案,就一路把後面的 diff 全部帶進去,沒有在下一個 `diff --git` 區塊時停下來。最小重現:diff 同時有 `a.js` 和 `b.js`,要補 `a.js` 的行號時,送給 LLM 的內容會混進 `b.js` 的 hunks,結果很容易定位到錯的行,或讓模型把別檔的內容誤認成目標檔上下文。", "problem": "這個抽取器一旦命中目標檔案,就一路把後面的 diff 全部帶進去,沒有在下一個 `diff --git` 區塊時停下來。最小重現:diff 同時有 `a.js` 和 `b.js`,要補 `a.js` 的行號時,送給 LLM 的內容會混進 `b.js` 的 hunks,結果很容易定位到錯的行,或讓模型把別檔的內容誤認成目標檔上下文。",
"suggestion": "在開始捕捉後,遇到下一個 `diff --git ` 就應該停止,只回傳目前檔案那一段;找不到目標檔時再退回整份 diff。", "suggestion": "在開始捕捉後,遇到下一個 `diff --git ` 就應該停止,只回傳目前檔案那一段;找不到目標檔時再退回整份 diff。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -173,46 +173,70 @@
"location": "src/findings.js:277", "location": "src/findings.js:277",
"problem": "`deduplicateWithAI` 是新的核心語意去重流程,但目前完全沒有直接測試它的成功與失敗分支。尤其是 LLM 回傳排序不同、夾雜幻覺項目、回傳空陣列或超量結果時,程式會改走保守 fallback,這些都是很容易壞掉但現在沒被驗證的邊界。", "problem": "`deduplicateWithAI` 是新的核心語意去重流程,但目前完全沒有直接測試它的成功與失敗分支。尤其是 LLM 回傳排序不同、夾雜幻覺項目、回傳空陣列或超量結果時,程式會改走保守 fallback,這些都是很容易壞掉但現在沒被驗證的邊界。",
"suggestion": "替 `deduplicateWithAI` 補測兩類情境:一是 stub `chatJSON` 回傳重排後的重複項與一筆幻覺項,確認只保留能對應回原始 findings 的資料;二是回傳空陣列或不合法結果時,確認會保守回傳原始 findings。", "suggestion": "替 `deduplicateWithAI` 補測兩類情境:一是 stub `chatJSON` 回傳重排後的重複項與一筆幻覺項,確認只保留能對應回原始 findings 的資料;二是回傳空陣列或不合法結果時,確認會保守回傳原始 findings。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/findings.js:293",
"problem": "每筆缺行號的 finding 都是單獨處理,還在每筆裡串行重試最多 3 次 LLM 查詢。N 筆問題就會膨脹成 N×3 次遠端呼叫與等待,幾十筆時延遲會直接被放大好幾倍。",
"suggestion": "把行號定位改成可平行的批次流程,例如同檔或同角色一起送出後用 `Promise.allSettled` 收結果;重試只保留在單筆失敗時,別讓每個 finding 都自己拖慢整條管線。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/findings.js:442",
"problem": "這裡每一筆 finding 都要跟整包 exclusions 做一次 `.some()`,而且內層還反覆跑 `normalizeText` 和字串包含比對,資料一多就直接變成 O(F×E) 的熱點。像 300 筆 finding 配 500 筆 exclusions,會吃掉 15 萬次以上的比對與正規化,CPU 和字串配置都在浪費。",
"suggestion": "先把 exclusions 在載入時一次正規化並依 `filePath / role / textKey` 建索引,讓過濾改成近似 O(F);至少把 `normalizeText` 移到內層迴圈外,避免同一段字串被重算成百上千次。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/gitea.js:239",
"problem": "這裡逐一 await 每個 review 的 commentsPR review 一多就變成 N 次遠端呼叫的線性延遲累加;例如 30 個 review 就是 30 個 round-trip 排隊等,時間都被網路空轉偷走。",
"suggestion": "把 reviews.map(review => getPullReviewComments(review.id).catch(...)) 丟進 Promise.all 或 Promise.allSettled 平行抓取,再 flat 結果;單筆失敗仍可記 warn 後略過。",
"is_new": false "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
"role": "Mage", "role": "Rogue",
"location": "src/comments.js:40", "location": "src/findings.js:322",
"problem": "這裡把 `file:0` 也視為有效行號;最小重現:只要上游傳進 `app/foo.js:0``parseLocation()` 會回傳 line=0,後續 `postPullReviewComment` 會帶著 `new_position: 0` 發到 Gitea,通常會被拒絕或定位失敗。也就是說,0 行號沒有被當成缺值處理。", "problem": "每一筆缺行號的 finding 都重新呼叫 `extractFileDiff(diff, file)` 掃完整份 diff,若同一檔案有 k 筆問題,就會重複做 k 次整份 diff 解析,浪費量是 O(k × diff長度)。",
"suggestion": "把行號門檻改成 `> 0`,`0` 與負數都應視為無效;同時讓需要行號的呼叫端把這種情況當作缺行號,重新定位或降級處理。", "suggestion": "先把 diff 依檔名切成快取 Map,一次掃描建立好 `file -> fileDiff`,後續同檔 finding 直接共用已切好的片段。",
"is_new": false "is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "src/llm.js:56",
"problem": "`cliArgs` 把不同提供者的參數拼湊在同一個分支裡,還讓 `opencode` 走了另一套文字輸入路線,整個 helper 的節奏忽然一分為二。讀起來像兩個介面硬塞進同一支笛子。",
"suggestion": "拆成各提供者各自的 argv builder,或至少把 prompt 輸入方式抽成獨立 helper,讓每個分支只處理一種責任,結構會更俐落。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "src/findings.js:1",
"problem": "這個模組同時處理舊 findings 載入、合併去重、缺行號補齊、排除規則正規化、誤報過濾、AI 去重、以及 exclusions 的讀寫,職責已經混成一包。更麻煩的是 `loadExclusions`、`appendExclusions`、`applyExclusions` 各自都有一套相近但不完全一致的比對邏輯,未來只要規則改一處,另一處沒同步就會開始出現不可預期的行為差異。",
"suggestion": "把 exclusions 的正規化與比對規則抽成唯一來源,例如 `normalizeExclusionEntry` + `matchesExclusion` 之類的共用 helper,並把 AI 去重、行號補齊、檔案持久化拆到不同模組,減少這個檔案的責任面。",
"is_new": true
}, },
{ {
"level": "warning", "level": "warning",
"role": "Mage", "role": "Mage",
"location": "src/main.js:69", "location": "src/json.js:113",
"problem": "這裡檢查 head SHA 對應的訊息是否為 failure,但如果 SHA 查詢失敗或是空值,後面的 `shouldSkipBotCommit()` 仍可能只看到分支 head 上的 `[ai-review-bot]` 標記就直接跳過。最小重現:`getCommitMessageBySha()` 因 Gitea API 暫時失敗回空字串,而分支 head 正好是 `[ai-review-bot][failure]`,流程就會 exit 0,等於把本來應該失敗的 bot commit 當成可跳過的自動提交。", "problem": "這裡檢查 `JSON.parse(normalized)` 能不能成功,沒有確認修復後的內容真的是陣列。最小重現是 AI 把 `findings.json` 修成 `{ \"a\": 1 }`,函式會照樣寫回檔案並回報成功,但下一輪讀取時 `readJSONArray` 會把它當成非陣列而視為空值,等於把資料靜默吃掉。",
"suggestion": "把「是否跳過」和「是否 failure」分開判斷,或讓 helper 回傳解析出的 outcome;只允許 success 標記走 skipfailure 標記不論 SHA/branch 來源都應優先讓流程失敗。", "suggestion": "在寫檔前先 `const parsed = JSON.parse(normalized)`,再加上 `Array.isArray(parsed)` 檢查;不是陣列就直接丟錯,不要覆寫原檔。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/resolve.js:74",
"problem": "這裡用 `path + line` 當唯一群組鍵,且只保留第一筆 `botFinding`。最小重現是同一個檔案同一行同時被兩個角色指出不同問題,`groupConversations` 會把它們合成同一組,後來的那筆 finding 會被吞掉,導致後續關閉、回寫或保留時少掉一個問題。",
"suggestion": "不要只用 `path + line` 折疊所有 comment;至少要保留同一組內的所有 botFinding,或改成以 comment id / finding 本身為單位處理,再在最後階段做去重。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "src/comments.js:214",
"problem": "這裡新增了 `postOldFindingsComment` 與 `postNewNonCriticalComment` 兩條公開的留言分流路徑,但現有測試只驗證了 `postNewCriticalComments` 與 `postFindingsReview`,完全沒有案例確認這兩個函式的篩選條件、空陣列時是否跳過、以及輸出的 Markdown 內容是否真的只包含對應的 findings。這種分流邏輯一旦算錯,就會發生該發的沒發、或不該公告的問題被貼出去。",
"suggestion": "補上這兩個函式的單元測試:空陣列時不呼叫 `postComment``postOldFindingsComment` 只送出 `is_new === false` 的項目;`postNewNonCriticalComment` 只送出 `is_new` 且 `level !== 'critical'` 的項目;再斷言 comment 標題與表格列數都符合預期。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "src/comments.js:171",
"problem": "`postFindingsReview` 的救援路徑只測到「批次 review 失敗後,改發逐筆 inline comment」這一段,卻沒有驗證第二次 `postReview({ comments: [] })` 也失敗時,會正確降級到 `postIssue(body)`。這條路徑是 Gitea review API 整個故障時保住摘要的最後保險絲,沒測到的話,真正出事時很容易靜默漏報。",
"suggestion": "新增一個雙重失敗測試:第一次 `postReview` 因 comments 拋錯、第二次 `postReview` 也拋錯,最後斷言有呼叫 `postIssue`,而且 inline comments 仍會依序嘗試發布。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "src/main.js:17",
"problem": "`main()` 整個流程目前沒有任何直接測試,只能靠零散的子函式單測推測結果;但這裡包含多個關鍵分支與 `process.exit` 行為,例如 preflight 失敗、bot 自動提交跳過、空 diff 提早結束、JSON 驗證失敗、以及偵測到 critical 後結束失敗。只要接線順序或退出碼改壞,現有測試不會第一時間抓到。",
"suggestion": "補一組整合測試,把 `runPreflight`、`getPRDiff`、`reconcileConversations`、`validateJSONArrayFile`、`commitAndPush` 以 stub 注入,逐一覆蓋 Step3/5/9/11 的 exit 0/1 分支,至少驗證 `process.exit` 與主要副作用被正確觸發。",
"is_new": true "is_new": true
}, },
{ {
@@ -229,7 +253,7 @@
"location": "src/comments.js:102", "location": "src/comments.js:102",
"problem": "統計表與單行摘要各欄位都用 `filter(...).length` 重掃多次,同一批 findings 會被走 4 到 8 次。資料量一大,連 log 文字本身都開始吃不必要的掃描成本。", "problem": "統計表與單行摘要各欄位都用 `filter(...).length` 重掃多次,同一批 findings 會被走 4 到 8 次。資料量一大,連 log 文字本身都開始吃不必要的掃描成本。",
"suggestion": "改成單次迴圈同時累加 critical / warning / info / 未分類計數,再把結果組成表格和摘要;一次走完就好,別讓統計自己變熱點。", "suggestion": "改成單次迴圈同時累加 critical / warning / info / 未分類計數,再把結果組成表格和摘要;一次走完就好,別讓統計自己變熱點。",
"is_new": true "is_new": false
}, },
{ {
"level": "info", "level": "info",
@@ -237,7 +261,7 @@
"location": "src/comments.js:266", "location": "src/comments.js:266",
"problem": "`postOldFindingsComment` 與緊接著的 `postNewNonCriticalComment` 都是這次新加的對外 comment 發布行為,但目前沒有專門測試它們的空陣列早退、標題文字與表格內容。這會讓 comment 分流邏輯只靠間接測試支撐,回歸時很容易漏掉。", "problem": "`postOldFindingsComment` 與緊接著的 `postNewNonCriticalComment` 都是這次新加的對外 comment 發布行為,但目前沒有專門測試它們的空陣列早退、標題文字與表格內容。這會讓 comment 分流邏輯只靠間接測試支撐,回歸時很容易漏掉。",
"suggestion": "補這兩個函式的單元測試:至少驗證空陣列時不會送 comment、非空時 body 內容包含正確標題與表格,且 `postOldFindingsComment` 只收舊問題、`postNewNonCriticalComment` 只收新非 critical 問題。", "suggestion": "補這兩個函式的單元測試:至少驗證空陣列時不會送 comment、非空時 body 內容包含正確標題與表格,且 `postOldFindingsComment` 只收舊問題、`postNewNonCriticalComment` 只收新非 critical 問題。",
"is_new": true "is_new": false
}, },
{ {
"level": "info", "level": "info",
@@ -245,7 +269,7 @@
"location": "src/findings.js:393", "location": "src/findings.js:393",
"problem": "前面已經把 exclusions 正規化、去重過一次了,這裡為了 log 又再丟進 `buildExclusionContext` 重做 normalize / dedupe / group。等於同一批資料在同一輪流程裡被重算兩次,白白多吃一輪 O(n) 到 O(n log n) 的 CPU。", "problem": "前面已經把 exclusions 正規化、去重過一次了,這裡為了 log 又再丟進 `buildExclusionContext` 重做 normalize / dedupe / group。等於同一批資料在同一輪流程裡被重算兩次,白白多吃一輪 O(n) 到 O(n log n) 的 CPU。",
"suggestion": "把第一次處理的摘要一起回傳或快取下來,後面的 log 直接重用同一份結果,不要再對同一批 exclusions 重跑分組。", "suggestion": "把第一次處理的摘要一起回傳或快取下來,後面的 log 直接重用同一份結果,不要再對同一批 exclusions 重跑分組。",
"is_new": true "is_new": false
}, },
{ {
"level": "info", "level": "info",
@@ -253,6 +277,22 @@
"location": "src/resolve.js:88", "location": "src/resolve.js:88",
"problem": "這個 `codeWindow` 每遇到一筆 open conversation 就對整份檔案內容再 `split('\\n')` 一次。若同一個檔案有多條 thread,O(L) 的切割和陣列配置會被重複吃掉,明明同一份內容卻一直重複解剖。", "problem": "這個 `codeWindow` 每遇到一筆 open conversation 就對整份檔案內容再 `split('\\n')` 一次。若同一個檔案有多條 thread,O(L) 的切割和陣列配置會被重複吃掉,明明同一份內容卻一直重複解剖。",
"suggestion": "先把檔案內容預先切成行陣列並快取,或讓 `codeWindow` 直接吃已分割好的 lines;這樣同檔多條對話就不用重複掃描整份內容。", "suggestion": "先把檔案內容預先切成行陣列並快取,或讓 `codeWindow` 直接吃已分割好的 lines;這樣同檔多條對話就不用重複掃描整份內容。",
"is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "src/resolve.js:201",
"problem": "`reconcileConversations()` 同時在做 comment 分組、關閉遠端 review、讀檔、抽 code window、AI 裁決、再把結果拆成 resolved / excluded / carried 三條路徑,流程很完整,但也很難局部理解或替換。未來任何一段判斷要調整,都得先吞下整個函式的心智負擔,維護門檻偏高。",
"suggestion": "把它拆成幾個可單獨測試的步驟,例如 `collectOpenConversations()`、`loadConversationCode()`、`judgeConversationVerdicts()`、`mapVerdictsToFindings()`,讓主流程只保留編排,不要把資料轉換與外部副作用全塞在一起。",
"is_new": true
},
{
"level": "info",
"role": "Mage",
"location": "src/usage.js:212",
"problem": "這個百分比計算只擋了 `limit <= 0`,沒有擋 `remaining < 0`。最小重現是 `resolveRemainingPercent({ available: true, used: 150, limit: 100 }, null)` 或 `remaining = -1`,會算出負百分比,讓使用量摘要出現不合理的 `-50%` 之類結果,和函式註解宣告的「負數視為無法計算」不一致。",
"suggestion": "把 `remaining < 0` 也納入無效值判斷,直接回 `null`;若你想保守顯示,可另外在輸出層把負值夾到 0,但不要讓計算層回傳負百分比。",
"is_new": true "is_new": true
} }
] ]
+1
View File
@@ -22,6 +22,7 @@ jobs:
name: 2. TEST name: 2. TEST
runs-on: ubuntu runs-on: ubuntu
needs: [build] needs: [build]
if: ${{ gitea.base_ref == 'develop' }}
env: env:
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
steps: steps:
+4
View File
@@ -8,12 +8,16 @@ jobs:
name: DEPLOY name: DEPLOY
runs-on: ubuntu runs-on: ubuntu
env: env:
GITEA_CONTEXT: ${{ toJSON(gitea) }}
COMMIT_SHA: ${{ gitea.event.commits[1].id }} COMMIT_SHA: ${{ gitea.event.commits[1].id }}
steps: steps:
- name: Show Gitea Context
run: echo "$GITEA_CONTEXT" | jq .
- name: Source Code Checkout - name: Source Code Checkout
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
- name: Get Commit Tag - name: Get Commit Tag
id: commit id: commit
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT
+19 -17
View File
@@ -1,6 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { chatJSON } from './llm.js'; import { chatJSON, mapWithConcurrency, LLM_CONCURRENCY } from './llm.js';
import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js'; import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js';
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { line, ok, warn } from './log.js'; import { line, ok, warn } from './log.js';
@@ -368,14 +368,14 @@ function extractFileDiff(diff, file) {
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。 * 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
*/ */
export async function resolveMissingLineNumbers(findings, diff, deps = {}) { export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS } = deps; const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
let resolved = 0; // 只挑「缺行號且有檔名」的 finding;各自以獨立 LLM 子行程並行定位(併發上限見 concurrency)。
let pending = 0; const pending = findings.filter(f => findingLine(f.location) == null
for (const f of findings) { && String(f.location || '').split(',')[0].split(':')[0].trim());
if (findingLine(f.location) != null) continue; // 已有行號 if (pending.length === 0) return findings;
const outcomes = await mapWithConcurrency(pending, concurrency, async (f) => {
const file = String(f.location || '').split(',')[0].split(':')[0].trim(); const file = String(f.location || '').split(',')[0].split(':')[0].trim();
if (!file) continue;
pending += 1;
const systemPrompt = buildLocateLinePrompt(getRole(f.role) || { name: f.role }); const systemPrompt = buildLocateLinePrompt(getRole(f.role) || { name: f.role });
const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`; const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`;
let located = null; let located = null;
@@ -390,12 +390,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
} }
if (located != null) { if (located != null) {
f.location = `${file}:${located}`; f.location = `${file}:${located}`;
resolved += 1; return true;
} else {
warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
} }
} warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`); return false;
});
ok(`補行號: ${outcomes.filter(Boolean).length}/${pending.length} 筆成功定位`);
return findings; return findings;
} }
@@ -576,10 +577,11 @@ export async function filterFalsePositivesWithAI(findings, exclusions = [], chat
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。` ? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
: ''; : '';
// 每條 finding 各派一個防守方 sub-agent 裁決,多條時平行處理 // 每條 finding 各派一個防守方 sub-agent 裁決;併發上限與其他 LLM 任務共用 LLM_CONCURRENCY(預設不限制)。
const verdicts = await Promise.all( const verdicts = await mapWithConcurrency(findings, LLM_CONCURRENCY, async (f) => ({
findings.map(f => judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn).then(isFP => ({ f, isFP }))), f,
); isFP: await judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn),
}));
const kept = verdicts.filter(v => !v.isFP).map(v => v.f); const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length}`); ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length}`);
return kept; return kept;
+33
View File
@@ -6,6 +6,39 @@ import { getLLMConfig } from './config.js';
import { recordUsage } from './usage.js'; import { recordUsage } from './usage.js';
import { line } from './log.js'; import { line } from './log.js';
// 每個 LLM CLI 呼叫(角色分析、補行號等)都是一個獨立子行程。預設「不限制」併發(全部同時跑);
// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
// 0 / 未設定 / 非正整數 → 不限制。
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;
/**
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
*
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
* fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。
* @template T, R
* @param {T[]} items - 要處理的項目。
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
*/
export async function mapWithConcurrency(items, limit, fn) {
const list = Array.isArray(items) ? items : [];
const results = new Array(list.length);
if (list.length === 0) return results;
const n = Number(limit);
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
let cursor = 0;
async function run() {
while (cursor < list.length) {
const i = cursor++;
results[i] = await fn(list[i], i);
}
}
await Promise.all(Array.from({ length: workers }, run));
return results;
}
/** /**
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。 * 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
*/ */
+11 -4
View File
@@ -9,6 +9,7 @@ import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatU
import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
import { runPreflight } from './preflight.js'; import { runPreflight } from './preflight.js';
import { mapWithConcurrency, LLM_CONCURRENCY } from './llm.js';
import { section, step, line, input, output, result, warn, error } from './log.js'; import { section, step, line, input, output, result, warn, error } from './log.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
@@ -120,15 +121,21 @@ async function main() {
} catch (e) { } catch (e) {
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
} }
// 各角色以獨立 LLM 子行程並行分析(併發上限見 LLM_CONCURRENCY),單一角色失敗僅 warn 後跳過。
const newFindings = []; const newFindings = [];
let fulfilledAnalyses = 0; let fulfilledAnalyses = 0;
for (const role of roles) { const roleResults = await mapWithConcurrency(roles, LLM_CONCURRENCY, async (role) => {
try { try {
const findings = await analyzeWithRole(role, diff); return await analyzeWithRole(role, diff);
fulfilledAnalyses += 1;
newFindings.push(...findings);
} catch (e) { } catch (e) {
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`); warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
return null;
}
});
for (const findings of roleResults) {
if (findings) {
fulfilledAnalyses += 1;
newFindings.push(...findings);
} }
} }
if (fulfilledAnalyses === 0) { if (fulfilledAnalyses === 0) {
+40 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises'; import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { extractBalancedJSON, extractJSONText, extractMeaningfulError } from '../llm.js'; import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
const ENV_KEYS = [ const ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER', 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
@@ -273,3 +273,42 @@ describe('extractMeaningfulError', () => {
assert.equal(extractMeaningfulError(null), ''); assert.equal(extractMeaningfulError(null), '');
}); });
}); });
describe('mapWithConcurrency', () => {
it('回傳與輸入同索引對應的結果(保序)', async () => {
const out = await mapWithConcurrency([1, 2, 3, 4], 2, async (n) => n * 10);
assert.deepEqual(out, [10, 20, 30, 40]);
});
it('遵守併發上限(同時執行數不超過 limit)', async () => {
let active = 0, peak = 0;
const wait = () => new Promise(r => setTimeout(r, 5));
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => {
active += 1; peak = Math.max(peak, active);
await wait();
active -= 1;
});
assert.ok(peak <= 2, `peak=${peak} 應 <= 2`);
});
it('limit 大於項目數時仍全部執行', async () => {
const out = await mapWithConcurrency(['a', 'b'], 10, async (s) => s.toUpperCase());
assert.deepEqual(out, ['A', 'B']);
});
it('limit<=0 表示不限制(全部同時並行)', async () => {
let active = 0, peak = 0;
const wait = () => new Promise(r => setTimeout(r, 5));
await mapWithConcurrency([1, 2, 3, 4, 5], 0, async () => {
active += 1; peak = Math.max(peak, active);
await wait();
active -= 1;
});
assert.equal(peak, 5, `peak=${peak} 應等於項目數(不限制)`);
});
it('空輸入回傳空陣列', async () => {
assert.deepEqual(await mapWithConcurrency([], 3, async () => 1), []);
assert.deepEqual(await mapWithConcurrency(null, 3, async () => 1), []);
});
});