diff --git a/TODO.md b/TODO.md index 9ae6a31..f10c08f 100644 --- a/TODO.md +++ b/TODO.md @@ -21,8 +21,8 @@ - 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。 ## 階段五:AI 排除問題過濾 -- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)時先去除重複條目、整理成語意群組摘要,再進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。 -- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、重複排除條目的整理摘要、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。 +- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)時先去除重複條目、整理成語意群組摘要;若檔案不是頂層陣列格式,需主動修正成正確格式,再進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。 +- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、重複排除條目的整理摘要、格式修正訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。 - 部分驗收:log 已顯示 `讀取排除問題: 50 筆` 與 `排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。 - 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`。 diff --git a/app/findings.js b/app/findings.js index d069e59..5d7d03d 100644 --- a/app/findings.js +++ b/app/findings.js @@ -37,10 +37,22 @@ function readJSONArray(fullPath, label) { function normalizeExclusions(data) { if (Array.isArray(data)) return data; + if (data && Array.isArray(data.exclusions)) return data.exclusions; if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings; return []; } +function detectExclusionSource(data) { + if (Array.isArray(data)) return 'array'; + if (data && Array.isArray(data.exclusions)) return 'exclusions'; + if (data && Array.isArray(data.excluded_findings)) return 'excluded_findings'; + return 'unknown'; +} + +function writeCanonicalExclusions(fullPath, exclusions) { + fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8'); +} + function formatFileTime(mtimeMs) { if (!Number.isFinite(mtimeMs)) return 'unknown'; return new Date(mtimeMs).toISOString(); @@ -278,14 +290,20 @@ export function loadExclusions(workspace, repoState = null) { try { const stat = fs.statSync(fullPath); const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); - rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0; - exclusions = dedupeExclusions(normalizeExclusions(data).map((exclusion, index) => normalizeExclusionEntry(exclusion, index))); + const sourceFormat = detectExclusionSource(data); + const normalizedSource = normalizeExclusions(data); + rawCount = normalizedSource.length; + exclusions = dedupeExclusions(normalizedSource.map((exclusion, index) => normalizeExclusionEntry(exclusion, index))); const branch = repoState?.branch || 'detached'; const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown'; const commitTime = repoState?.commitTime || 'unknown'; line(`讀取排除問題檔案: ${fullPath}`); line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); line(`檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} raw=${rawCount} normalized=${exclusions.length} path=${path.relative(workspace, fullPath) || fullPath}`); + if (sourceFormat !== 'array') { + writeCanonicalExclusions(fullPath, normalizedSource); + line(`排除問題格式已修正為頂層陣列: source=${sourceFormat} -> array`); + } } catch (e) { warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); exclusions = []; diff --git a/app/findings.test.js b/app/findings.test.js index c1204f4..fc945fd 100644 --- a/app/findings.test.js +++ b/app/findings.test.js @@ -41,6 +41,25 @@ describe('findings exclusions', () => { assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead'); }); + it('repairs exclusions wrapper format to a top-level array', () => { + const fullPath = path.join(workspace, EXCLUSIONS_PATH); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, JSON.stringify({ + exclusions: [ + { location: 'README.md:12', suggestion: 'keep' }, + ], + }, null, 2)); + + const exclusions = loadExclusions(workspace); + const repaired = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + + assert.equal(exclusions.length, 1); + assert.ok(Array.isArray(repaired)); + assert.equal(repaired[0].location, 'README.md:12'); + assert.equal(repaired[0].suggestion, 'keep'); + assert.ok(logs.some(line => line.includes('排除問題格式已修正為頂層陣列: source=exclusions -> array'))); + }); + it('applies exclusions loaded from wrapper format', () => { const findings = [ { location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },