Compare commits

...

16 Commits

Author SHA1 Message Date
jiantw83 09584f4f93 chore: triage ai review findings 2026-05-18 02:55:43 +00:00
AI Review Bot ed061f85ce chore: update ai-review findings [ai-review-bot][success] 2026-05-18 02:53:34 +00:00
jiantw83 b4c54124ec feat: force overwrite core instruction files 2026-05-18 02:50:47 +00:00
jiantw83 b51ab78a5e feat: force sync skill trees 2026-05-18 02:48:54 +00:00
AI Review Bot 1129f37384 chore: update ai-review findings [ai-review-bot][success] 2026-05-18 02:43:56 +00:00
jiantw83 b8294d5ca7 fix: persist repaired exclusions 2026-05-18 02:40:53 +00:00
jiantw83 915e9cc2da docs: require canonical exclusions array 2026-05-18 02:35:35 +00:00
jiantw83 b1ed236720 feat: normalize exclusions format 2026-05-18 02:33:24 +00:00
jiantw83 d18c4a4a8e feat: optimize exclusion filtering 2026-05-18 02:06:36 +00:00
jiantw83 b06a89f2b9 更新 README.md 2026-05-15 16:06:02 +00:00
jiantw83 bb0158dadd Merge pull request 'chore: refine pipeline stage logs' (#119) from feat/美化輸出 into develop
Reviewed-on: #119
2026-05-15 15:54:33 +00:00
AI Review Bot ce6afdd5ee chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:53:01 +00:00
jiantw83 86d8666cda test: cover log helpers 2026-05-15 15:51:56 +00:00
AI Review Bot 95e90393e7 chore: update ai-review findings [ai-review-bot][success] 2026-05-15 15:46:29 +00:00
jiantw83 c836ec08e4 chore: triage log output suggestions 2026-05-15 15:45:08 +00:00
jiantw83 f382667946 Merge pull request 'feat: 美化輸出' (#118) from feat/美化輸出 into develop
Reviewed-on: #118
2026-05-15 15:32:15 +00:00
16 changed files with 437 additions and 54 deletions
+2 -1
View File
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
- info - info
3. Renumber from 1. 3. Renumber from 1.
4. Fix real issues. 4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible. 5. Put false positives into `.gitea/ai-review/exclusions.json` as a top-level JSON array, preserving the original wording, language, and semantics as much as possible. Do not wrap the array in `exclusions` or `excluded_findings`.
6. Add tests when behavior changes. 6. Add tests when behavior changes.
## Output Rules ## Output Rules
@@ -26,4 +26,5 @@ description: Triage findings, fix real issues, and exclude false positives.
- Keep the final list short. - Keep the final list short.
- Keep numbering contiguous. - Keep numbering contiguous.
- Preserve file path, location, and fix. - Preserve file path, location, and fix.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text over paraphrased rewrites. - When writing exclusions, prefer the original issue text over paraphrased rewrites.
+2 -1
View File
@@ -21,7 +21,7 @@ It is also used when some findings are false positives and should be moved into
4. Renumber the sorted list from 1 upward. 4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently. 5. Rewrite each finding concisely so the final list reads cleanly and consistently.
6. If a finding is a false positive, do not keep it in the final list. 6. If a finding is a false positive, do not keep it in the final list.
7. Add false positives to the exclusions list using the existing schema in the repo or task context, and preserve the original finding wording as much as possible, including language and semantics. 7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`.
## Resolution Flow ## Resolution Flow
@@ -41,5 +41,6 @@ After the list is merged and ordered, resolve the remaining findings one by one.
- Keep numbering contiguous after filtering and merging. - Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix. - Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema. - Keep exclusions entries minimal and consistent with the project schema.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema. - When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering. - If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
@@ -1,4 +1,4 @@
interface: interface:
display_name: "Triage Findings" display_name: "Triage Findings"
short_description: "Triage, sort, fix, and exclude review findings" short_description: "Triage, sort, fix, and exclude review findings"
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to exclusions." default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to `.gitea/ai-review/exclusions.json` as a top-level JSON array."
+2 -1
View File
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
- info - info
3. Renumber from 1. 3. Renumber from 1.
4. Fix real issues. 4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible. 5. Put false positives into `.gitea/ai-review/exclusions.json` as a top-level JSON array, preserving the original wording, language, and semantics as much as possible. Do not wrap the array in `exclusions` or `excluded_findings`.
6. Add tests when behavior changes. 6. Add tests when behavior changes.
## Output Rules ## Output Rules
@@ -26,4 +26,5 @@ description: Triage findings, fix real issues, and exclude false positives.
- Keep the final list short. - Keep the final list short.
- Keep numbering contiguous. - Keep numbering contiguous.
- Preserve file path, location, and fix. - Preserve file path, location, and fix.
- When writing exclusions, always output a top-level JSON array.
- When writing exclusions, prefer the original issue text over paraphrased rewrites. - When writing exclusions, prefer the original issue text over paraphrased rewrites.
+15
View File
@@ -154,6 +154,11 @@
"location": "app/main.js", "location": "app/main.js",
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務" "suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
}, },
{
"role": "Maya",
"location": "app/log.test.js",
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
},
{ {
"role": "Rex", "role": "Rex",
"location": "app/package.json", "location": "app/package.json",
@@ -329,5 +334,15 @@
"role": "Leo", "role": "Leo",
"location": "action.yaml:80", "location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。" "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
},
{
"role": "Rex",
"location": "action.yaml:18",
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
},
{
"role": "Leo",
"location": "app/log.js",
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
} }
] ]
+1 -23
View File
@@ -1,23 +1 @@
[ []
{
"level": "info",
"role": "Rex",
"location": "action.yaml:18",
"suggestion": "引入 GITEA_COMMENT_TOKEN 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 GITEA_TOKEN 類似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。",
"is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "app/log.js",
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。",
"is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "app/log.js:19",
"suggestion": "在 `warn` 函式中使用 `console.warn` 而非 `console.log`。雖然目前功能相同,但 `console.warn` 在某些環境下(例如瀏覽器開發者工具)會以不同的樣式呈現警告訊息,有助於區分不同嚴重程度的日誌。",
"is_new": false
}
]
+1 -1
View File
@@ -7,7 +7,7 @@ Use the triage-finding workflow for review issue lists:
3. Sort by severity: `critical` -> `warning` -> `info`. 3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1. 4. Renumber from 1.
5. Fix real issues with the smallest safe change. 5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible. 6. Put false positives into `.gitea/ai-review/exclusions.json` as a top-level JSON array, preserving the original wording, language, and semantics as much as possible. Do not wrap the array in `exclusions` or `excluded_findings`.
7. Add or update tests when behavior changes. 7. Add or update tests when behavior changes.
8. Re-check after each fix. 8. Re-check after each fix.
+6
View File
@@ -57,6 +57,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key
OPENAI_BASE_URL: https://api.openai.com/v1 OPENAI_BASE_URL: https://api.openai.com/v1
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
@@ -86,6 +87,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }} OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }},${{ secrets.OPENROUTER_API_KEY_1 }}
OPENAI_BASE_URL: https://openrouter.ai/api/v1 OPENAI_BASE_URL: https://openrouter.ai/api/v1
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
@@ -115,6 +117,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
CLAUDE_BASE_URL: https://api.anthropic.com/v1 CLAUDE_BASE_URL: https://api.anthropic.com/v1
permissions: permissions:
@@ -143,6 +146,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }}
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
@@ -172,6 +176,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
AMAZONQ_BASE_URL: https://q.api.aws AMAZONQ_BASE_URL: https://q.api.aws
permissions: permissions:
@@ -201,6 +206,7 @@ jobs:
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1 OLLAMA_BASE_URL: https://ollama.jsc.idv.me/v1
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
permissions: permissions:
+2 -2
View File
@@ -21,8 +21,8 @@
- 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。 - 已驗收:log 已出現 `AI 去重: 13 -> 11 筆`,且程式具備失敗時保留所有問題的降級處理。
## 階段五:AI 排除問題過濾 ## 階段五:AI 排除問題過濾
- 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`)進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。 - 目標:讀取排除問題檔案(`.gitea/ai-review/exclusions.json`時先去除重複條目、整理成語意群組摘要;若檔案不是頂層陣列格式,需主動修正成正確格式,再進行規則過濾並呼叫 AI 判斷剩餘問題是否為誤報或不適用,兩層過濾後產生最終問題清單。
- 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。 - 驗收:log 中能看到排除問題檔案讀取成功或不存在的訊息、重複排除條目的整理摘要、格式修正訊息、規則過濾數量變化,以及「AI 誤報過濾: N -> M 筆」或降級訊息。
- 部分驗收:log 已顯示 `讀取排除問題: 50 筆``排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。 - 部分驗收:log 已顯示 `讀取排除問題: 50 筆``排除過濾: 11 -> 0 筆`,但這次未進入 `AI 誤報過濾: N -> M 筆` 的正向路徑。
- 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題` - 可驗收紀錄情境:當 `排除過濾` 後仍保留 1 筆以上 findings 時,log 會出現 `AI 誤報過濾: N -> M 筆`;若 API 額度不足或回傳失敗,則會出現 `AI 誤報過濾失敗(...),降級:保留所有問題`
+174 -10
View File
@@ -37,15 +37,161 @@ function readJSONArray(fullPath, label) {
function normalizeExclusions(data) { function normalizeExclusions(data) {
if (Array.isArray(data)) return 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; if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
return []; 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) { function formatFileTime(mtimeMs) {
if (!Number.isFinite(mtimeMs)) return 'unknown'; if (!Number.isFinite(mtimeMs)) return 'unknown';
return new Date(mtimeMs).toISOString(); return new Date(mtimeMs).toISOString();
} }
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeText(value) {
return cleanText(value)
.normalize('NFKC')
.toLowerCase()
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function toKeyText(value) {
return cleanText(value)
.normalize('NFKC')
.replace(/[\p{P}\p{S}\s]+/gu, '')
.trim();
}
function getExclusionText(exclusion) {
return cleanText(exclusion?.original_finding)
|| cleanText(exclusion?.title)
|| cleanText(exclusion?.suggestion)
|| cleanText(exclusion?.reason)
|| cleanText(exclusion?.note);
}
function normalizeExclusionEntry(exclusion, index) {
const location = cleanText(exclusion?.location);
const filePath = location ? location.split(':')[0] : '';
const role = cleanText(exclusion?.role);
const text = getExclusionText(exclusion);
const textKey = toKeyText(text);
const fingerprint = [filePath || '*', role || '*', textKey || `entry-${index + 1}`].join('|');
return {
...exclusion,
location: location || null,
filePath,
role: role || null,
text,
textKey,
fingerprint,
};
}
function dedupeExclusions(exclusions) {
const seen = new Set();
return exclusions.filter(exclusion => {
if (seen.has(exclusion.fingerprint)) return false;
seen.add(exclusion.fingerprint);
return true;
});
}
function groupExclusionsForAI(exclusions) {
const groups = new Map();
for (const exclusion of exclusions) {
const groupKey = exclusion.textKey || exclusion.fingerprint;
if (!groups.has(groupKey)) {
groups.set(groupKey, {
key: groupKey,
text: exclusion.text || exclusion.location || exclusion.fingerprint,
count: 0,
paths: new Set(),
roles: new Set(),
samples: [],
});
}
const group = groups.get(groupKey);
group.count += 1;
if (exclusion.filePath) group.paths.add(exclusion.filePath);
if (exclusion.role) group.roles.add(exclusion.role);
if (group.samples.length < 2 && exclusion.text) group.samples.push(exclusion.text);
}
return [...groups.values()]
.sort((a, b) => b.count - a.count || b.paths.size - a.paths.size || a.text.localeCompare(b.text))
.map(group => ({
text: group.text,
count: group.count,
paths: [...group.paths].sort(),
roles: [...group.roles].sort(),
samples: group.samples,
}));
}
function buildExclusionContext(exclusions) {
if (exclusions.length === 0) {
return {
rawCount: 0,
uniqueCount: 0,
groups: [],
prompt: '',
};
}
const normalized = exclusions.map((exclusion, index) => normalizeExclusionEntry(exclusion, index));
const unique = dedupeExclusions(normalized);
const groups = groupExclusionsForAI(unique);
const topGroups = groups.slice(0, 12).map(group => ({
text: group.text,
count: group.count,
paths: group.paths.slice(0, 4),
roles: group.roles.slice(0, 3),
samples: group.samples.slice(0, 2),
}));
const omitted = groups.length - topGroups.length;
const promptLines = [
`已知誤報清單(原始 ${exclusions.length} 筆,整理後 ${unique.length} 筆,分成 ${groups.length} 類):`,
...topGroups.map((group, index) => {
const parts = [
`${index + 1}. ${group.text}`,
`count=${group.count}`,
];
if (group.paths.length > 0) parts.push(`paths=${group.paths.join(', ')}`);
if (group.roles.length > 0) parts.push(`roles=${group.roles.join(', ')}`);
if (group.samples.length > 0) parts.push(`samples=${group.samples.join(' | ')}`);
return `- ${parts.join(' ; ')}`;
}),
];
if (omitted > 0) {
promptLines.push(`- 另有 ${omitted} 類相似排除條目未展開,請依上述群組規則推論。`);
}
return {
rawCount: exclusions.length,
uniqueCount: unique.length,
groupCount: groups.length,
groups: topGroups,
prompt: promptLines.join('\n'),
};
}
/** /**
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH * 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH
*/ */
@@ -126,7 +272,7 @@ export async function deduplicateWithAI(findings) {
/** /**
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH * 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH
*/ */
export function loadExclusions(workspace, repoState = null) { export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
warn(`排除問題檔案不存在,視為空: ${fullPath}`); warn(`排除問題檔案不存在,視為空: ${fullPath}`);
@@ -144,19 +290,31 @@ export function loadExclusions(workspace, repoState = null) {
try { try {
const stat = fs.statSync(fullPath); const stat = fs.statSync(fullPath);
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
rawCount = Array.isArray(data) ? data.length : Array.isArray(data?.excluded_findings) ? data.excluded_findings.length : 0; const sourceFormat = detectExclusionSource(data);
exclusions = normalizeExclusions(data); const normalizedSource = normalizeExclusions(data);
rawCount = normalizedSource.length;
exclusions = dedupeExclusions(normalizedSource.map((exclusion, index) => normalizeExclusionEntry(exclusion, index)));
const branch = repoState?.branch || 'detached'; const branch = repoState?.branch || 'detached';
const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown'; const shortSha = repoState?.shortSha || repoState?.headSha || 'unknown';
const commitTime = repoState?.commitTime || 'unknown'; const commitTime = repoState?.commitTime || 'unknown';
line(`讀取排除問題檔案: ${fullPath}`); line(`讀取排除問題檔案: ${fullPath}`);
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${commitTime}`); 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}`); 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);
if (mirrorWorkspace && path.resolve(mirrorWorkspace) !== path.resolve(workspace)) {
const mirrorPath = path.join(mirrorWorkspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
writeCanonicalExclusions(mirrorPath, normalizedSource);
}
line(`排除問題格式已修正為頂層陣列: source=${sourceFormat} -> array`);
}
} catch (e) { } catch (e) {
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`); warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
exclusions = []; exclusions = [];
} }
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length}`); const summary = buildExclusionContext(exclusions);
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} groups=${summary.groupCount}`);
return exclusions; return exclusions;
} }
@@ -169,8 +327,13 @@ export function applyExclusions(findings, exclusions) {
const before = findings.length; const before = findings.length;
const filtered = findings.filter(f => !exclusions.some(ex => { const filtered = findings.filter(f => !exclusions.some(ex => {
const fPath = String(f.location).split(':')[0]; const fPath = String(f.location).split(':')[0];
const exPath = ex.location ? String(ex.location).split(':')[0] : null; const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
return (!exPath || fPath === exPath) && (!ex.role || ex.role === f.role); const findingText = normalizeText(f.suggestion || f.title || '');
const exclusionText = ex.textKey || normalizeText(ex.text || ex.suggestion || ex.title || '');
const locationMatches = (!exPath || fPath === exPath);
const roleMatches = (!ex.role || ex.role === f.role);
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
return locationMatches && roleMatches && (exPath || ex.role ? true : textMatches);
})); }));
ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`); ok(`排除過濾: ${before} -> ${filtered.length} 筆(排除 ${before - filtered.length} 筆)`);
return filtered; return filtered;
@@ -179,17 +342,18 @@ export function applyExclusions(findings, exclusions) {
/** /**
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings * 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
*/ */
export async function filterFalsePositivesWithAI(findings, exclusions = []) { export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
if (findings.length === 0) return findings; if (findings.length === 0) return findings;
const exclusionHint = exclusions.length > 0 const exclusionContext = buildExclusionContext(exclusions);
? `\n已知誤報(相同路徑且語意相近者一併排除):\n${JSON.stringify(exclusions.map(({ location, suggestion }) => ({ location, suggestion })))}` const exclusionHint = exclusionContext.prompt
? `\n${exclusionContext.prompt}\n規則:若 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
: ''; : '';
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`; const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
try { try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) { if (Array.isArray(result) && result.length > 0) {
ok(`AI 誤報過濾: ${findings.length} -> ${result.length}`); ok(`AI 誤報過濾: ${findings.length} -> ${result.length}`);
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
+86 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { loadOldFindings, loadExclusions, applyExclusions } from './findings.js'; import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js'; import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
describe('findings exclusions', () => { describe('findings exclusions', () => {
@@ -41,6 +41,47 @@ describe('findings exclusions', () => {
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead'); 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('mirrors repaired exclusions into the workspace root when requested', () => {
const repoRoot = path.join(workspace, 'repo');
const mirrorRoot = path.join(workspace, 'workspace');
const repoFullPath = path.join(repoRoot, EXCLUSIONS_PATH);
const mirrorFullPath = path.join(mirrorRoot, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(repoFullPath), { recursive: true });
fs.mkdirSync(path.dirname(mirrorFullPath), { recursive: true });
fs.writeFileSync(repoFullPath, JSON.stringify({
exclusions: [
{ location: 'README.md:12', suggestion: 'keep' },
],
}, null, 2));
const exclusions = loadExclusions(repoRoot, null, mirrorRoot);
const mirror = JSON.parse(fs.readFileSync(mirrorFullPath, 'utf8'));
assert.equal(exclusions.length, 1);
assert.ok(Array.isArray(mirror));
assert.equal(mirror[0].location, 'README.md:12');
assert.equal(mirror[0].suggestion, 'keep');
});
it('applies exclusions loaded from wrapper format', () => { it('applies exclusions loaded from wrapper format', () => {
const findings = [ const findings = [
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' }, { location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
@@ -56,6 +97,50 @@ describe('findings exclusions', () => {
assert.equal(filtered[0].location, 'README.md:12'); assert.equal(filtered[0].location, 'README.md:12');
}); });
it('dedupes repeated exclusions when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify([
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
{ location: 'entrypoint.sh:999', title: 'fetch_package_versions jq overhead' },
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
], null, 2));
const exclusions = loadExclusions(workspace);
assert.equal(exclusions.length, 1);
assert.equal(exclusions[0].filePath, 'entrypoint.sh');
assert.equal(exclusions[0].text, 'fetch_package_versions jq overhead');
});
it('builds a compact exclusion hint for AI', async () => {
const findings = [
{ level: 'warning', role: 'Maya', location: 'src/app.cs:12', suggestion: 'update tests' },
];
const exclusions = [
{ location: 'src/app.cs:1', original_finding: '更新套件後請補上測試驗證' },
{ location: 'src/app.cs:99', original_finding: '更新套件後請補上測試驗證 ' },
{ location: 'src/service.cs:3', original_finding: '更新套件後請補上測試驗證' },
{ location: 'src/service.cs:8', title: '請確認安全性變更' },
];
let capturedSystemPrompt = '';
let capturedUserContent = '';
const result = await filterFalsePositivesWithAI(findings, exclusions, async (systemPrompt, userContent) => {
capturedSystemPrompt = systemPrompt;
capturedUserContent = userContent;
return findings;
});
assert.equal(result.length, 1);
assert.ok(capturedSystemPrompt.includes('已知誤報清單(原始 4 筆,整理後 3 筆,分成 2 類)'));
assert.ok(capturedSystemPrompt.includes('更新套件後請補上測試驗證'));
assert.ok(capturedSystemPrompt.includes('paths=src/app.cs, src/service.cs'));
assert.ok(capturedSystemPrompt.includes('請確認安全性變更'));
assert.ok(capturedUserContent.includes('"location":"src/app.cs:12"'));
assert.ok(capturedUserContent.includes('"suggestion":"update tests"'));
});
it('logs exclusions file metadata and repo state when loading exclusions', () => { it('logs exclusions file metadata and repo state when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+60 -10
View File
@@ -20,6 +20,17 @@ export const SYNC_PATHS = [
'CLAUDE.md', 'CLAUDE.md',
'GEMINI.md', 'GEMINI.md',
]; ];
const FORCE_SYNC_FILE_PATHS = [
'.github/copilot-instructions.md',
'CLAUDE.md',
'GEMINI.md',
];
const SYNC_TREE_PATHS = [
'.codex/skills/triage-findings',
'.claude/skills/triage-findings',
'.gemini/skills/triage-findings',
'.github/skills/triage-findings',
];
function makeRunner(spawn) { function makeRunner(spawn) {
return function run(args, cwd, env) { return function run(args, cwd, env) {
@@ -51,6 +62,35 @@ function readGitOutput(run, args, cwd, env) {
} }
} }
function copyTree(sourceRoot, repoDir, relDir) {
const srcDir = path.join(sourceRoot, relDir);
if (!fs.existsSync(srcDir)) return [];
const copied = [];
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const relPath = path.join(relDir, entry.name);
const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath);
if (entry.isDirectory()) {
copied.push(...copyTree(sourceRoot, repoDir, relPath));
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
copied.push(relPath);
}
return copied;
}
function copyFileOverwrite(sourceRoot, repoDir, relPath) {
const src = path.join(sourceRoot, relPath);
if (!fs.existsSync(src)) return null;
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
return relPath;
}
export function getRepoState(repoDir, _spawnSync = spawnSync) { export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
@@ -101,21 +141,31 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir); run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
} }
const existingSyncPaths = []; const existingSyncPaths = new Set();
// Copy action skill files into the target repo. Existing files are overwritten; // Copy action skill trees into the target repo. Existing files are overwritten;
// missing source files are ignored so we do not delete target repo content. // missing source files are ignored so we do not delete target repo content.
for (const relPath of SYNC_PATHS) { for (const relDir of SYNC_TREE_PATHS) {
const src = path.join(sourceRoot, relPath); for (const relPath of copyTree(sourceRoot, repoDir, relDir)) {
const dest = path.join(repoDir, relPath); existingSyncPaths.add(relPath);
if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
existingSyncPaths.push(relPath);
} }
} }
if (existingSyncPaths.length > 0) { // Force overwrite the direct instruction files first so the target repo always
// receives the action-owned versions even if the repo has drifted.
for (const relPath of FORCE_SYNC_FILE_PATHS) {
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied);
}
// Copy standalone action files into the target repo. Existing files are overwritten.
for (const relPath of SYNC_PATHS) {
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied);
}
if (existingSyncPaths.size > 0) {
run(['add', ...existingSyncPaths], repoDir); run(['add', ...existingSyncPaths], repoDir);
} }
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath))); const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
+24 -1
View File
@@ -159,11 +159,30 @@ describe('commitAndPush', () => {
const repoDir = path.join(workspace, 'repo'); const repoDir = path.join(workspace, 'repo');
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale'); fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'stale');
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md'); assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md');
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md'); assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
assert.equal(fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8'), 'GEMINI.md');
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
});
it('recursively overwrites skill tree files from the action source', async () => {
const repoDir = path.join(workspace, 'repo');
const nestedRelPath = '.codex/skills/triage-findings/assets/example.txt';
const sourceNestedPath = path.join(sourceRoot, nestedRelPath);
const repoNestedPath = path.join(repoDir, nestedRelPath);
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
fs.writeFileSync(sourceNestedPath, 'fresh');
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
fs.writeFileSync(repoNestedPath, 'stale');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh');
}); });
it('does not throw when git command fails', async () => { it('does not throw when git command fails', async () => {
@@ -185,12 +204,16 @@ describe('commitAndPush', () => {
}); });
const logs = []; const logs = [];
const originalLog = console.log; const originalLog = console.log;
console.log = (...args) => { logs.push(args.join(' ')); }; const originalWarn = console.warn;
const capture = (...args) => { logs.push(args.join(' ')); };
console.log = capture;
console.warn = capture;
try { try {
await commitAndPush(workspace, repoDir, spawn, sourceRoot); await commitAndPush(workspace, repoDir, spawn, sourceRoot);
} finally { } finally {
console.log = originalLog; console.log = originalLog;
console.warn = originalWarn;
} }
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗'))); assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
+1 -1
View File
@@ -15,7 +15,7 @@ export function ok(message) {
} }
export function warn(message) { export function warn(message) {
console.log(` ! ${message}`); console.warn(` ! ${message}`);
} }
export function error(message) { export function error(message) {
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { section, step, line, ok, warn, error } from './log.js';
afterEach(() => mock.restoreAll());
describe('log helpers', () => {
it('formats section and step messages', () => {
const calls = [];
mock.method(console, 'log', (...args) => {
calls.push(args.join(' '));
});
section('Pipeline');
step('Step1', 'Start');
assert.deepEqual(calls, [
'\n=== Pipeline ===',
'\n[Step1] Start',
]);
});
it('formats line and ok messages with console.log', () => {
const calls = [];
mock.method(console, 'log', (...args) => {
calls.push(args.join(' '));
});
line('hello');
ok('done');
assert.deepEqual(calls, [
' - hello',
' ✓ done',
]);
});
it('formats warn messages with console.warn', () => {
const calls = [];
mock.method(console, 'warn', (...args) => {
calls.push(args.join(' '));
});
warn('careful');
assert.deepEqual(calls, [' ! careful']);
});
it('formats error messages with console.error', () => {
const calls = [];
mock.method(console, 'error', (...args) => {
calls.push(args.join(' '));
});
error('boom');
assert.deepEqual(calls, [' x boom']);
});
});
+1 -1
View File
@@ -95,7 +95,7 @@ async function main() {
ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); ok(`Step3 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆 (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
step('Step4', 'AI 排除問題過濾'); step('Step4', 'AI 排除問題過濾');
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
const ruleFiltered = applyExclusions(sorted, exclusions); const ruleFiltered = applyExclusions(sorted, exclusions);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
ok(`Step4 完成: findings total=${filtered.length}`); ok(`Step4 完成: findings total=${filtered.length}`);