import fs from 'fs'; import path from 'path'; import { chatJSON, mapWithConcurrency, LLM_CONCURRENCY } 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|{exclusions?: Array, excluded_findings?: Array}|*} data - 任意形式的排除資料來源。 * @returns {Array} 對應的排除條目陣列;無法辨識時回傳空陣列。 * @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|{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} 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} exclusions - 已正規化(含 fingerprint)的排除條目陣列。 * @returns {Array} 去重後的排除條目陣列。 * @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} 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} exclusions - 原始(未正規化)排除條目陣列。 * @returns {{rawCount: number, uniqueCount: number, groupCount?: number, groups: Array, 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, concurrency = LLM_CONCURRENCY } = deps; // 只挑「缺行號且有檔名」的 finding;各自以獨立 LLM 子行程並行定位(併發上限見 concurrency)。 const pending = findings.filter(f => findingLine(f.location) == null && String(f.location || '').split(',')[0].split(':')[0].trim()); 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 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}`; return true; } warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`); return false; }); ok(`補行號: ${outcomes.filter(Boolean).length}/${pending.length} 筆成功定位`); return findings; } /** * 將 findings 精簡為僅含 level、role、location、problem、suggestion 的物件,移除多餘欄位以節省 token。 * * @param {Array} 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))); // 去重結果數量不得超過輸入(避免 LLM 無中生有),且每筆都必須能對應回原始 finding。 if (Array.isArray(result) && result.length > 0 && result.length <= findings.length) { const keyOf = f => `${f.location}|${String(f.suggestion).slice(0, 50)}`; const origMap = new Map(findings.map(f => [keyOf(f), f])); // 只保留能對應回原始 finding 的項目,丟棄無法對應(可能為幻覺)的結果 const mapped = result.map(r => origMap.get(keyOf(r))).filter(Boolean); if (mapped.length > 0) { ok(`AI 去重: ${findings.length} -> ${mapped.length} 筆`); return mapped; } } throw new Error('AI 去重結果異常(空、超量或無法對應原始 findings)'); } 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 裁決;併發上限與其他 LLM 任務共用 LLM_CONCURRENCY(預設不限制)。 const verdicts = await mapWithConcurrency(findings, LLM_CONCURRENCY, async (f) => ({ f, isFP: await judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn), })); const kept = verdicts.filter(v => !v.isFP).map(v => v.f); ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`); return kept; }