Files
ai-code-review/.gitea/ai-review/findings.json
T
AI Review Bot 0be25e667c
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 28s
CI / 3. RESULT (pull_request) Has been skipped
chore: update ai-review findings [ai-review-bot][failure]
2026-07-03 09:04:44 +00:00

299 lines
24 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[
{
"level": "critical",
"role": "Assassin",
"location": "src/resolve.js:253",
"problem": "這裡會把 PR 上所有未解決的 review comment ID 全部送去 resolve,而不是只處理 AI Review bot 自己建立的 thread。攻擊者只要開 PR 觸發這個 action,就可能讓 bot 關閉人類審查者留下的安全疑慮或阻擋性對話,繞過人工審查流程。",
"suggestion": "只 resolve 可證明由本 bot 建立且格式符合預期的 comment,例如檢查作者、固定 marker、review body 簽章或 botFinding 解析結果;人類留言與未知格式留言不得自動關閉。",
"is_new": false
},
{
"level": "critical",
"role": "Maya",
"location": "src/main.js:44",
"problem": "這個 `main()` 是整個 action 的流程總管,但目前沒有任何整合測試或端到端測試去驗證 Step3~Step11 的分支切換與 `process.exit()` 行為。像是前置驗證失敗、偵測到 bot 自動提交、diff 為空、所有角色分析都失敗、JSON 驗證失敗、出現 critical finding、以及 commit/push 降級路徑,現在都只靠人工推演,實際接線後一旦流程順序或退出條件出錯,現有單元測試抓不到。",
"suggestion": "補一組 `main.test.js`,把各模組依賴都 mock 掉,分別覆蓋 `runPreflight=false`、`shouldSkipBotCommit=true`、`getPRDiff=''`、分析全失敗、JSON 驗證拋錯、filtered 含 critical、push 失敗但流程不中斷等分支,並斷言對應的 exit code、呼叫順序與關鍵 log。",
"is_new": false
},
{
"level": "critical",
"role": "Assassin",
"location": "src/gitea.js:66",
"problem": "這裡直接從 PR head 讀取 `.reviewignore`,再拿它當成排除規則。攻擊者可以在自己的分支塞入排除條目,讓 bot 故意跳過包含惡意變更的檔案或整個目錄,等於自己決定哪些地方不被審查。",
"suggestion": "不要信任 PR head 裡的 `.reviewignore` 來決定安全掃描範圍;改從受保護的 base branch 或 maintainer 管控的位置讀取,且要與固定的預設排除清單合併,而不是讓它覆蓋預設規則。",
"is_new": false
},
{
"level": "critical",
"role": "Assassin",
"location": "src/findings.js:352",
"problem": "這裡會直接讀取 PR 工作樹中的 `.gitea/ai-review/exclusions.json` 當成可信排除來源。攻擊者可以先在分支裡放一份藏在 `.gitea/` 下的 exclusions 檔,利用被忽略的路徑把自己的問題先排除掉,讓後續的 findings 被靜默吃掉。",
"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
},
{
"level": "warning",
"role": "Assassin",
"location": "src/llm.js:21",
"problem": "這裡把未清洗的 `userContent` 直接塞進模型提示詞,等於讓 PR 內容、留言內容或其他外部文字能反過來操控 LLM。攻擊者可以在 diff 裡埋入『忽略前述規則、回傳空陣列』這類指令,讓審查模型漏報真正的風險或把嚴重問題降級成誤報。",
"suggestion": "不要把不可信內容當成可執行指令使用。至少要把 diff/留言做更強的結構化封裝與逸出處理,並在輸出端加上嚴格的 JSON schema 驗證與 deterministic guardrail,避免 LLM 直接決定安全性結論。",
"is_new": false
},
{
"level": "warning",
"role": "Mage",
"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 當成可跳過的自動提交。",
"suggestion": "把「是否跳過」和「是否 failure」分開判斷,或讓 helper 回傳解析出的 outcome;只允許 success 標記走 skipfailure 標記不論 SHA/branch 來源都應優先讓流程失敗。",
"is_new": false
},
{
"level": "warning",
"role": "Rogue",
"location": "src/main.js:132",
"problem": "這裡把每個角色的 LLM 分析逐一 await,6 個角色就把總耗時堆成約 6 倍單次模型延遲;這些分析彼此獨立,CPU 沒偷到時間,反而把整條 pipeline 卡在序列網路/CLI 呼叫上。",
"suggestion": "改用 Promise.allSettled 平行執行 roles.map(role => analyzeWithRole(role, diff)),再彙整 fulfilled 結果與 warning;保留 fulfilledAnalyses 的判斷即可。",
"is_new": false
},
{
"level": "warning",
"role": "Leo",
"location": "src/findings.js:381",
"problem": "`loadExclusions()` 同時負責讀檔、解析多種格式、正規化、去重、記錄 repo 狀態、改寫原檔、鏡像寫入與建立 AI prompt 摘要。這個函式的職責過多,之後只要調整 exclusions 格式或同步策略,就很容易牽動不相關行為。",
"suggestion": "拆成 `readExclusionsFile()`、`normalizeExclusionsData()`、`canonicalizeExclusionsFile()`、`logExclusionMetadata()` 等小函式,讓讀取、轉換、寫回與診斷各自可測。",
"is_new": false
},
{
"level": "warning",
"role": "Mage",
"location": "src/findings.js:466",
"problem": "這裡優先使用 `ex.textKey`,但 `textKey` 是由 `toKeyText()` 產生的無分隔且未轉小寫文字,而 findingText 是 `normalizeText()` 產生的小寫、以空白分隔文字。最小重現:排除文字 `Update tests` 會變成 `Updatetests`finding suggestion 會變成 `update tests`,兩邊互相 `includes` 都不成立,導致純文字排除規則失效。",
"suggestion": "排除條目與 finding 應使用同一套正規化函式比對;例如改存並使用 `normalizeText(ex.text || ex.suggestion || ex.title || '')`,或讓 finding 也轉成同樣的 compact/lowercase key。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "src/findings.js:416",
"problem": "applyExclusions 的核心比對支援「只有文字、沒有路徑/角色」的排除規則,但現有測試多半靠相同檔案路徑命中,沒有驗證純文字排除、空文字排除、大小寫/標點差異等邊界。這條排除規則的最脆弱分支還沒被測到。",
"suggestion": "補上 applyExclusions 的邊界測試:只有 suggestion/title 文字沒有 location 的 exclusion 應如何比對;空文字 exclusion 不應意外排除全部;標點、空白、大小寫正規化後相同的文字應依預期排除。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "src/gitea.js:95",
"problem": "shouldSkipBotCommit 目前只看到命中 bot marker 的測試,缺少「commit API 失敗、分支查詢失敗、sha/branch 都沒有 marker」時應回 false 的失敗與保守路徑驗證。這是避免 workflow 誤跳過審查的關鍵判斷,不能只測快樂路徑。",
"suggestion": "新增測試讓 getCommitMessageBySha / getBranchHeadCommitMessage 對應的 axios 呼叫拋錯或回一般 commit message,斷言 shouldSkipBotCommit 回 false,且不會把查詢失敗誤判成 bot commit。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "src/llm.js:66",
"problem": "`runAssistantCLI()` 目前只有成功與一般失敗的測試,沒有覆蓋 timeout、`maxBuffer` 超限、以及 `opencode` 分支建立的暫存 prompt 檔在例外發生時是否確實清理。這些都是外部 CLI 整合最常出問題的失敗路徑,沒有測到就很難確定不會留下殘檔或把流程卡死。",
"suggestion": "補 fake CLI 測試,讓子程序超時、輸出超過 `AI_ASSISTANT_MAX_BUFFER`、以及 `opencode` 在 `spawn`/`close` 前後失敗,分別斷言會回傳對應錯誤,且暫存目錄與 `prompt.md` 會被清掉。",
"is_new": false
},
{
"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
},
{
"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": false
},
{
"level": "warning",
"role": "Mage",
"location": "src/resolve.js:213",
"problem": "`findingSig` 只用檔案路徑加上 `suggestion` 來識別問題,忽略了 `problem`、`role`,也沒有留下任何穩定的 thread 識別;最小重現:同一個 `a.js` 內有兩條都建議「加上 null 檢查」但其實是不同位置的 finding,先解掉其中一條後,另一條也會被當成同一筆而被 `dropResolvedFindings` / `addCarriedFindings` 誤合併或誤刪。",
"suggestion": "把識別鍵改成更穩定的組合,例如檔案路徑 + 正規化後的 `problem` + `suggestion` + `role`,或直接使用可追蹤的 thread/issue id;不要只靠 `suggestion` 斷言是不是同一個問題。",
"is_new": false
},
{
"level": "warning",
"role": "Mage",
"location": "src/comments.js:40",
"problem": "這裡把 `file:0` 也視為有效行號;最小重現:只要上游傳進 `app/foo.js:0``parseLocation()` 會回傳 line=0,後續 `postPullReviewComment` 會帶著 `new_position: 0` 發到 Gitea,通常會被拒絕或定位失敗。也就是說,0 行號沒有被當成缺值處理。",
"suggestion": "把行號門檻改成 `> 0`,`0` 與負數都應視為無效;同時讓需要行號的呼叫端把這種情況當作缺行號,重新定位或降級處理。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "src/comments.js:233",
"problem": "`postFindingsReview` 的降級流程有兩層:先嘗試批次 review,再失敗時改成 summary-only,最後 summary-only 也失敗才退回一般 comment。現在的測試只驗到第一層失敗後、第二層成功的情境,沒有驗證 summary-only 也失敗時是否真的會呼叫 `postIssue(body)`,這是最脆弱的 fallback 路徑之一。",
"suggestion": "新增一個測試讓第一次 `postReview({comments})` 失敗、第二次 `postReview({comments: []})` 也失敗,然後斷言 `postIssue(body)` 有被呼叫,且 inline comments 仍會逐筆嘗試送出。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "src/config.js:31",
"problem": "這裡是整個 action 讀取 `INPUT_*`、`GITEA_*` 與事件 payload 的入口,但測試只覆蓋了 `getLLMConfig()`,沒有把 `GITEA_TOKEN`、`GITEA_COMMENT_TOKEN`、`PR_NUMBER`、`PR_HEAD_SHA` 這些環境與 payload 的優先序鎖住。特別是 comment token 退回主 token、以及 event 檔讀不到時回到空值的情境,都是 CI 最容易因環境差異壞掉的地方。",
"suggestion": "新增 config 相關測試,分別用假 `process.env` 和暫存 event payload 檔驗證:`INPUT_*` 會蓋過 ambient env、`GITEA_COMMENT_TOKEN` 缺值時會 fallback 到主 token、`GITEA_EVENT_PATH` / `GITHUB_EVENT_PATH` 讀取失敗時不會拋錯且回傳預設值。",
"is_new": false
},
{
"level": "warning",
"role": "Bard",
"location": "src/config.js:27",
"problem": "這段註解已經跟著介面走音了。它宣稱使用端「只需傳 `with: token`」,但這次 action 其實已新增 `comment_token` 與 `model` 等輸入,註解仍停留在舊旋律,容易讓讀者誤判介面現況。",
"suggestion": "把這組說明改成與目前 inputs 一致,明確列出 `token`、`comment_token`、`model` 的優先序與用途;如果無法精簡,就直接移到 README 或設計文件,避免在程式中留下過時註記。",
"is_new": false
},
{
"level": "warning",
"role": "Mage",
"location": "src/findings.js:227",
"problem": "這個抽取器一旦命中目標檔案,就一路把後面的 diff 全部帶進去,沒有在下一個 `diff --git` 區塊時停下來。最小重現:diff 同時有 `a.js` 和 `b.js`,要補 `a.js` 的行號時,送給 LLM 的內容會混進 `b.js` 的 hunks,結果很容易定位到錯的行,或讓模型把別檔的內容誤認成目標檔上下文。",
"suggestion": "在開始捕捉後,遇到下一個 `diff --git ` 就應該停止,只回傳目前檔案那一段;找不到目標檔時再退回整份 diff。",
"is_new": false
},
{
"level": "warning",
"role": "Maya",
"location": "src/findings.js:277",
"problem": "`deduplicateWithAI` 是新的核心語意去重流程,但目前完全沒有直接測試它的成功與失敗分支。尤其是 LLM 回傳排序不同、夾雜幻覺項目、回傳空陣列或超量結果時,程式會改走保守 fallback,這些都是很容易壞掉但現在沒被驗證的邊界。",
"suggestion": "替 `deduplicateWithAI` 補測兩類情境:一是 stub `chatJSON` 回傳重排後的重複項與一筆幻覺項,確認只保留能對應回原始 findings 的資料;二是回傳空陣列或不合法結果時,確認會保守回傳原始 findings。",
"is_new": false
},
{
"level": "warning",
"role": "Rogue",
"location": "src/findings.js:322",
"problem": "每一筆缺行號的 finding 都重新呼叫 `extractFileDiff(diff, file)` 掃完整份 diff,若同一檔案有 k 筆問題,就會重複做 k 次整份 diff 解析,浪費量是 O(k × diff長度)。",
"suggestion": "先把 diff 依檔名切成快取 Map,一次掃描建立好 `file -> fileDiff`,後續同檔 finding 直接共用已切好的片段。",
"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",
"role": "Mage",
"location": "src/json.js:113",
"problem": "這裡只檢查 `JSON.parse(normalized)` 能不能成功,沒有確認修復後的內容真的是陣列。最小重現是 AI 把 `findings.json` 修成 `{ \"a\": 1 }`,函式會照樣寫回檔案並回報成功,但下一輪讀取時 `readJSONArray` 會把它當成非陣列而視為空值,等於把資料靜默吃掉。",
"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
},
{
"level": "info",
"role": "Maya",
"location": "src/comments.js:25",
"problem": "Markdown 表格列直接嵌入 role、location、suggestion,但測試沒有覆蓋 suggestion 含 `|`、換行或 Markdown 特殊字元時的輸出。這不是要求現在一定要改格式,而是目前缺少案例確認表格在真實 LLM 輸出下不會被破壞。",
"suggestion": "補一個 comment body 格式測試,輸入 suggestion 含 pipe、換行與粗體符號,斷言輸出的 Markdown 結構符合預期;若目前行為會破表,應先定義轉義或替換規則再測。",
"is_new": false
},
{
"level": "info",
"role": "Rogue",
"location": "src/comments.js:102",
"problem": "統計表與單行摘要各欄位都用 `filter(...).length` 重掃多次,同一批 findings 會被走 4 到 8 次。資料量一大,連 log 文字本身都開始吃不必要的掃描成本。",
"suggestion": "改成單次迴圈同時累加 critical / warning / info / 未分類計數,再把結果組成表格和摘要;一次走完就好,別讓統計自己變熱點。",
"is_new": false
},
{
"level": "info",
"role": "Maya",
"location": "src/comments.js:266",
"problem": "`postOldFindingsComment` 與緊接著的 `postNewNonCriticalComment` 都是這次新加的對外 comment 發布行為,但目前沒有專門測試它們的空陣列早退、標題文字與表格內容。這會讓 comment 分流邏輯只靠間接測試支撐,回歸時很容易漏掉。",
"suggestion": "補這兩個函式的單元測試:至少驗證空陣列時不會送 comment、非空時 body 內容包含正確標題與表格,且 `postOldFindingsComment` 只收舊問題、`postNewNonCriticalComment` 只收新非 critical 問題。",
"is_new": false
},
{
"level": "info",
"role": "Rogue",
"location": "src/findings.js:393",
"problem": "前面已經把 exclusions 正規化、去重過一次了,這裡為了 log 又再丟進 `buildExclusionContext` 重做 normalize / dedupe / group。等於同一批資料在同一輪流程裡被重算兩次,白白多吃一輪 O(n) 到 O(n log n) 的 CPU。",
"suggestion": "把第一次處理的摘要一起回傳或快取下來,後面的 log 直接重用同一份結果,不要再對同一批 exclusions 重跑分組。",
"is_new": false
},
{
"level": "info",
"role": "Rogue",
"location": "src/resolve.js:88",
"problem": "這個 `codeWindow` 每遇到一筆 open conversation 就對整份檔案內容再 `split('\\n')` 一次。若同一個檔案有多條 thread,O(L) 的切割和陣列配置會被重複吃掉,明明同一份內容卻一直重複解剖。",
"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
}
]