feat(ai-code-review): 新增 AI 程式碼審查主程式、模組與測試
This commit is contained in:
+581
@@ -0,0 +1,581 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { chatJSON } from './llm.js';
|
||||
import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js';
|
||||
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||
import { line, ok, warn } from './log.js';
|
||||
|
||||
const LEVELS = ['critical', 'warning', 'info'];
|
||||
|
||||
/**
|
||||
* 用單一角色分析 diff,回傳 findings 陣列。
|
||||
* role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。
|
||||
*/
|
||||
export async function analyzeWithRole(role, diff) {
|
||||
line(`[${role.name}] 開始分析`);
|
||||
const findings = await chatJSON(buildAnalysisPrompt(role), `以下是 Git Diff 內容:\n\n${diff}`);
|
||||
const valid = findings.filter(f => f.level && f.location && f.suggestion)
|
||||
.map(f => ({ ...f, role: role.name, is_new: true }));
|
||||
ok(`[${role.name}] 找到 ${valid.length} 個問題`);
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列
|
||||
*/
|
||||
function readJSONArray(fullPath, label) {
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
warn(`${label}檔案不存在,視為空`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
warn(`讀取${label}失敗: ${e.message},視為空`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
||||
*
|
||||
* @param {Array<object>|{exclusions?: Array<object>, excluded_findings?: Array<object>}|*} data - 任意形式的排除資料來源。
|
||||
* @returns {Array<object>} 對應的排除條目陣列;無法辨識時回傳空陣列。
|
||||
* @remarks 與 detectExclusionSource 搭配,相容舊有多種 exclusions.json 結構。
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 偵測排除資料的原始容器格式,回傳格式標籤。
|
||||
*
|
||||
* @param {Array<object>|{exclusions?: *, excluded_findings?: *}|*} data - 任意形式的排除資料來源。
|
||||
* @returns {('array'|'exclusions'|'excluded_findings'|'unknown')} 對應的格式標籤。
|
||||
* @remarks 供 loadExclusions 判斷是否需把非陣列格式改寫成標準頂層陣列。
|
||||
*/
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 以標準格式(2 空白縮排 JSON 陣列、結尾換行、UTF-8)將排除條目寫回檔案,覆蓋原內容。
|
||||
*
|
||||
* @param {string} fullPath - 目標檔案路徑;上層目錄須事先存在(本函式不建立目錄)。
|
||||
* @param {Array<object>} exclusions - 欲寫入的排除條目陣列。
|
||||
* @returns {void}
|
||||
* @throws 檔案寫入失敗(權限不足、目錄不存在等)時拋出 fs 錯誤。
|
||||
* @remarks 統一輸出格式,使 exclusions.json 永遠是可預期的頂層陣列。
|
||||
*/
|
||||
function writeCanonicalExclusions(fullPath, exclusions) {
|
||||
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 將檔案 mtime(毫秒時間戳)格式化為 ISO 字串,無效值回傳 'unknown'。
|
||||
*
|
||||
* @param {number} mtimeMs - 毫秒時間戳(通常為 fs.Stats.mtimeMs)。
|
||||
* @returns {string} ISO 8601 時間字串,或在輸入非有限數時回傳 'unknown'。
|
||||
* @remarks 僅用於診斷日誌,呈現舊 findings / exclusions 檔案的修改時間。
|
||||
*/
|
||||
function formatFileTime(mtimeMs) {
|
||||
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
||||
return new Date(mtimeMs).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全取字串:字串則去頭尾空白,其餘型別(含 null/undefined/數字)一律回傳空字串。
|
||||
*
|
||||
* @param {*} value - 任意值。
|
||||
* @returns {string} 去除頭尾空白後的字串,或空字串。
|
||||
* @remarks 作為 normalizeText、toKeyText、getExclusionText 等的基礎防呆。
|
||||
*/
|
||||
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();
|
||||
export function normalizeText(value) {
|
||||
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
|
||||
const result = cleanText(value)
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (typeof value === 'string') _normalizeTextCache.set(value, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將文字壓縮成無分隔符的鍵值:NFKC 後移除所有標點/符號/空白。
|
||||
*
|
||||
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
|
||||
* @returns {string} 去除所有分隔符的緊湊字串(可能為空字串)。
|
||||
* @remarks 用於 normalizeExclusionEntry 的 textKey 與 fingerprint,以及群組鍵。
|
||||
* 不確定:是否刻意不轉小寫(與 normalizeText 不同),需人工確認此差異是否預期。
|
||||
*/
|
||||
function toKeyText(value) {
|
||||
return cleanText(value)
|
||||
.normalize('NFKC')
|
||||
.replace(/[\p{P}\p{S}\s]+/gu, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 從排除條目取出代表性文字,依優先序 original_finding > title > suggestion > reason > note 取第一個非空值。
|
||||
*
|
||||
* @param {object|null|undefined} exclusion - 排除條目物件(可為 null/undefined)。
|
||||
* @returns {string} 第一個非空的代表性文字,皆空時回傳空字串。
|
||||
* @remarks 供 normalizeExclusionEntry 產生比對文字;相容多種人工撰寫的排除欄位命名。
|
||||
*/
|
||||
function getExclusionText(exclusion) {
|
||||
return cleanText(exclusion?.original_finding)
|
||||
|| cleanText(exclusion?.title)
|
||||
|| cleanText(exclusion?.suggestion)
|
||||
|| cleanText(exclusion?.reason)
|
||||
|| cleanText(exclusion?.note);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正規化單一排除條目,補上 filePath、text、textKey 與唯一 fingerprint,保留原始欄位。
|
||||
*
|
||||
* @param {object} exclusion - 原始排除條目(可能僅含部分欄位)。
|
||||
* @param {number} index - 條目在來源陣列中的索引;無文字可用時用於產生 fallback 指紋(entry-N)。
|
||||
* @returns {object} 合併原欄位與衍生欄位(location、filePath、role、text、textKey、fingerprint)的新物件。
|
||||
* @remarks fingerprint 以 filePath|role|textKey 組成,缺值以 '*' 或 entry-N 補位,供 dedupeExclusions 去重。
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 fingerprint 去除重複的排除條目,保留首次出現者並維持原順序。
|
||||
*
|
||||
* @param {Array<object>} exclusions - 已正規化(含 fingerprint)的排除條目陣列。
|
||||
* @returns {Array<object>} 去重後的排除條目陣列。
|
||||
* @remarks 須先呼叫 normalizeExclusionEntry 補上 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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 將排除條目依 textKey 分組統計,產生供 AI prompt 使用的群組摘要(含出現次數、涉及路徑與角色、樣本)。
|
||||
*
|
||||
* @param {Array<object>} exclusions - 已正規化(含 textKey、filePath、role、text、fingerprint)的排除條目。
|
||||
* @returns {Array<{text: string, count: number, paths: string[], roles: string[], samples: string[]}>}
|
||||
* 依出現次數、涉及路徑數、文字字典序排序的群組摘要陣列。
|
||||
* @remarks 每組最多保留 2 筆樣本,避免後續 prompt 過長;供 buildExclusionContext 取前 N 組組裝提示。
|
||||
*/
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 由原始排除條目建立「已知誤報」上下文:正規化、去重、分組後,產生計數摘要與可直接嵌入 prompt 的文字。
|
||||
*
|
||||
* @param {Array<object>} exclusions - 原始(未正規化)排除條目陣列。
|
||||
* @returns {{rawCount: number, uniqueCount: number, groupCount?: number, groups: Array<object>, prompt: string}}
|
||||
* 含計數、前 12 組群組摘要與 prompt 字串;空輸入時 prompt 為空字串且不含 groupCount。
|
||||
* @remarks 供 loadExclusions 日誌與 filterFalsePositivesWithAI 組裝防守方提示使用;prompt 最多展開 12 類群組。
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
export function loadOldFindings(workspace) {
|
||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const stat = fs.statSync(fullPath);
|
||||
line(`讀取舊 findings 檔案: ${fullPath}`);
|
||||
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||
} else {
|
||||
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
||||
}
|
||||
ok(`讀取舊 findings: ${old.length} 筆`);
|
||||
return old;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
|
||||
*/
|
||||
export function mergeFindings(oldFindings, newFindings) {
|
||||
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
||||
const seen = new Set(oldFindings.map(key));
|
||||
const deduped = newFindings.filter(f => {
|
||||
if (seen.has(key(f))) return false;
|
||||
seen.add(key(f));
|
||||
return true;
|
||||
});
|
||||
const merged = [...oldFindings, ...deduped];
|
||||
ok(`合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依等級排序(critical > warning > info)
|
||||
*/
|
||||
export function sortByLevel(findings) {
|
||||
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 呼叫失敗時的統一降級處理
|
||||
*/
|
||||
function fallback(label, findings, e) {
|
||||
const status = e.response?.status;
|
||||
const reason = (status === 402 || status === 429) ? `${status} 額度/限流` : e.message;
|
||||
warn(`${label}失敗(${reason}),降級:保留所有問題`);
|
||||
return findings;
|
||||
}
|
||||
|
||||
const MAX_LOCATE_ATTEMPTS = 3;
|
||||
|
||||
/** 從 location 取出行號;無 `檔案:行號`(或多檔逗號)時回 null。 */
|
||||
function findingLine(location) {
|
||||
const s = String(location || '').trim();
|
||||
if (!s || s.includes(',')) return null;
|
||||
const m = /^(.+?):(\d+)(?:-\d+)?$/.exec(s);
|
||||
return m ? Number(m[2]) : null;
|
||||
}
|
||||
|
||||
/** 從整份 unified diff 擷取指定檔案的區段,找不到時回退整份 diff。 */
|
||||
function extractFileDiff(diff, file) {
|
||||
const lines = String(diff || '').split('\n');
|
||||
const out = [];
|
||||
let capturing = false;
|
||||
for (const l of lines) {
|
||||
if (l.startsWith('diff --git ')) capturing = l.includes(`b/${file}`) || l.includes(`a/${file}`);
|
||||
if (capturing) out.push(l);
|
||||
}
|
||||
return out.length ? out.join('\n') : String(diff || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號,
|
||||
* 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈);
|
||||
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
|
||||
*/
|
||||
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
||||
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS } = deps;
|
||||
let resolved = 0;
|
||||
let pending = 0;
|
||||
for (const f of findings) {
|
||||
if (findingLine(f.location) != null) continue; // 已有行號
|
||||
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 userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`;
|
||||
let located = null;
|
||||
for (let attempt = 1; attempt <= maxAttempts && located == null; attempt++) {
|
||||
try {
|
||||
const res = await chatFn(systemPrompt, userContent);
|
||||
const ln = Number(res?.line);
|
||||
if (Number.isInteger(ln) && ln > 0) located = ln;
|
||||
} catch (e) {
|
||||
warn(`[${f.role}] 行號定位失敗(第 ${attempt}/${maxAttempts} 次): ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (located != null) {
|
||||
f.location = `${file}:${located}`;
|
||||
resolved += 1;
|
||||
} else {
|
||||
warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
|
||||
}
|
||||
}
|
||||
if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`);
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 findings 精簡為僅含 level、role、location、problem、suggestion 的物件,移除多餘欄位以節省 token。
|
||||
*
|
||||
* @param {Array<object>} findings - 完整 findings 陣列。
|
||||
* @returns {Array<{level: *, role: *, location: *, problem: *, suggestion: *}>} 精簡後的 payload 陣列。
|
||||
* @remarks 送往 LLM 前的瘦身步驟;原始欄位(如 is_new)需由呼叫端事後依鍵補回。
|
||||
*/
|
||||
function toAIPayload(findings) {
|
||||
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
||||
*/
|
||||
export async function deduplicateWithAI(findings) {
|
||||
if (findings.length === 0) return findings;
|
||||
|
||||
const systemPrompt = `你是 🛡️ Paladin(聖騎士),這座程式碼競技場沉穩公正的裁判。攻擊方提出了一批程式碼審查問題(JSON 陣列)。請就事論事,把「同檔案位置 + 同問題本質」的重複指控合併,重複者只保留等級較高的一條(critical > warning > info)。只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
||||
|
||||
try {
|
||||
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||
if (Array.isArray(result) && result.length > 0) {
|
||||
ok(`AI 去重: ${findings.length} -> ${result.length} 筆`);
|
||||
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
|
||||
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
|
||||
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r);
|
||||
}
|
||||
throw new Error('AI 回傳空陣列');
|
||||
} catch (e) {
|
||||
return fallback('AI 去重', findings, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
||||
*/
|
||||
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
|
||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
warn(`排除問題檔案不存在,視為空: ${fullPath}`);
|
||||
if (repoState) {
|
||||
const branch = repoState.branch || 'detached';
|
||||
const shortSha = repoState.shortSha || repoState.headSha || 'unknown';
|
||||
line(`來源分支狀態: branch=${branch} commit=${shortSha} commit_time=${repoState.commitTime || 'unknown'}`);
|
||||
}
|
||||
ok('讀取排除問題: raw=0 normalized=0 筆');
|
||||
return [];
|
||||
}
|
||||
|
||||
let exclusions = [];
|
||||
let rawCount = 0;
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
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);
|
||||
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) {
|
||||
warn(`讀取排除問題失敗: ${e.message},視為空: ${fullPath}`);
|
||||
exclusions = [];
|
||||
}
|
||||
const summary = buildExclusionContext(exclusions);
|
||||
ok(`讀取排除問題: raw=${rawCount} normalized=${exclusions.length} groups=${summary.groupCount} 筆`);
|
||||
return exclusions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新的排除條目(raw 形式)append 到 exclusions.json,去重後以頂層陣列寫回 workspace 與 mirror。
|
||||
* 去重以「檔案路徑 + 正規化原文」為準。回傳合併後的 raw 陣列(無新增時回傳既有陣列)。
|
||||
*/
|
||||
export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) {
|
||||
if (!newEntries || newEntries.length === 0) return null;
|
||||
const fileOf = loc => String(loc || '').split(':')[0].trim();
|
||||
const sigOf = e => `${fileOf(e.location)}|${normalizeText(e.original_finding || e.suggestion || e.text || e.title || '')}`;
|
||||
|
||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||
let existing = [];
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
existing = normalizeExclusions(JSON.parse(fs.readFileSync(fullPath, 'utf8')));
|
||||
} catch (e) {
|
||||
warn(`讀取排除問題以追加失敗,視為空: ${e.message}`);
|
||||
existing = [];
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set(existing.map(sigOf));
|
||||
const additions = newEntries.filter(e => {
|
||||
const sig = sigOf(e);
|
||||
if (seen.has(sig)) return false;
|
||||
seen.add(sig);
|
||||
return true;
|
||||
});
|
||||
if (additions.length === 0) {
|
||||
line(`誤報排除無新增(皆已存在): 候選 ${newEntries.length} 筆`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const merged = [...existing, ...additions];
|
||||
const targets = [workspace];
|
||||
if (mirrorWorkspace && path.resolve(mirrorWorkspace) !== path.resolve(workspace)) targets.push(mirrorWorkspace);
|
||||
for (const dir of targets) {
|
||||
const target = path.join(dir, EXCLUSIONS_PATH);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeCanonicalExclusions(target, merged);
|
||||
}
|
||||
ok(`誤報寫入 exclusions: 新增 ${additions.length} 筆(總計 ${merged.length} 筆)`);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 套用排除規則,過濾掉符合排除條件的 findings
|
||||
* location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用
|
||||
*/
|
||||
export function applyExclusions(findings, exclusions) {
|
||||
if (exclusions.length === 0) return findings;
|
||||
const before = findings.length;
|
||||
const filtered = findings.filter(f => !exclusions.some(ex => {
|
||||
const fPath = String(f.location).split(':')[0];
|
||||
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
|
||||
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} 筆)`);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/** 派一個「防守方」sub-agent 裁決單一 finding 是否為誤報;任何失敗都保守視為成立(保留)。 */
|
||||
async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) {
|
||||
const systemPrompt = buildVerdictPrompt(defender, exclusionHint);
|
||||
try {
|
||||
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload([finding])[0]));
|
||||
return result?.verdict === 'false_positive';
|
||||
} catch (e) {
|
||||
warn(`誤報裁決失敗(保守視為成立): ${finding.location} error=${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「防守方」角色(Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
|
||||
* 多個問題時各派一個 sub-agent 平行裁決;任一裁決失敗保守保留該問題,不中斷流程。
|
||||
*/
|
||||
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
|
||||
if (findings.length === 0) return findings;
|
||||
|
||||
const defender = loadRole('Paladin');
|
||||
const exclusionContext = buildExclusionContext(exclusions);
|
||||
const exclusionHint = exclusionContext.prompt
|
||||
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
||||
: '';
|
||||
|
||||
// 每條 finding 各派一個防守方 sub-agent 裁決,多條時平行處理
|
||||
const verdicts = await Promise.all(
|
||||
findings.map(f => judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn).then(isFP => ({ f, isFP }))),
|
||||
);
|
||||
const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
|
||||
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`);
|
||||
return kept;
|
||||
}
|
||||
Reference in New Issue
Block a user