import type { CodeFinding, RawFalsePositiveAnalysis, VerifiedFinding } from '../index.js'; import { resolveAuth } from '../shared/auth.js'; import { chunkArray } from './batch.js'; import { buildVerificationPrompt, VERIFIER_SYSTEM_PROMPT } from './prompts.js'; const MAX_ATTEMPTS = 3; const RETRY_DELAYS_MS = [1000, 2000, 4000] as const; const INTER_BATCH_DELAY_MS = 500; const MAX_PROMPT_CHARS = 32_000; const SEVERITY_PRIORITY: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, UNDEFINED: 4, }; interface BatchStrategy { findingsToVerify: CodeFinding[]; skippedFindings: CodeFinding[]; batchSize: number; estimatedRequests: number; isLimited: boolean; } interface QwenChatCompletionResponse { choices?: Array<{ message?: { content?: string; }; }>; } interface QwenVerificationItem { finding_index: number; is_likely_fp: boolean; confidence: number; reason: string | null; explanation: string; } /** * Verifies code findings with Qwen in batches and annotates likely false positives. */ export async function verifyFindings( findings: CodeFinding[], options?: { verifyAll?: boolean }, ): Promise { if (findings.length === 0) { return []; } const strategy = calculateBatchStrategy(findings, options?.verifyAll === true); if (strategy.isLimited) { console.log(`⚠️ Large project: ${String(findings.length)} findings detected.`); console.log(' Verifying top 250 by severity (CRITICAL → HIGH → MEDIUM → LOW).'); console.log(' Use --verify-all flag to verify everything (uses more API quota).'); } const auth = resolveAuth(); const batches = chunkArray(strategy.findingsToVerify, strategy.batchSize); const verified: VerifiedFinding[] = []; for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { const batch = batches[batchIndex]; const displayIndex = batchIndex + 1; const displayTotal = batches.length; const analysis = await verifyBatchWithRetry(batch, auth); const batchVerified = mapBatchToVerifiedFindings(batch, analysis); verified.push(...batchVerified); console.log(` Verifying batch ${displayIndex}/${displayTotal}... ✓`); if (batchIndex < batches.length - 1) { await sleep(INTER_BATCH_DELAY_MS); } } if (strategy.skippedFindings.length > 0) { verified.push(...strategy.skippedFindings.map((finding) => buildQuotaSkippedFinding(finding))); } const totalConfirmed = verified.filter( (item) => item.fp_analysis.is_likely_fp === false && item.fp_analysis.reason !== 'verification_failed' && item.fp_analysis.reason !== 'verification_skipped_quota', ).length; const totalFiltered = verified.filter((item) => item.fp_analysis.is_likely_fp).length; console.log(` ✓ Verification complete: ${totalConfirmed} confirmed, ${totalFiltered} filtered as FP`); return verified; } function calculateBatchStrategy(findings: CodeFinding[], verifyAll: boolean): BatchStrategy { const total = findings.length; if (total <= 50) { return { findingsToVerify: findings, skippedFindings: [], batchSize: total, estimatedRequests: total === 0 ? 0 : 1, isLimited: false, }; } if (total <= 500) { const batchSize = 50; return { findingsToVerify: findings, skippedFindings: [], batchSize, estimatedRequests: Math.ceil(total / batchSize), isLimited: false, }; } if (verifyAll) { const batchSize = 50; return { findingsToVerify: findings, skippedFindings: [], batchSize, estimatedRequests: Math.ceil(total / batchSize), isLimited: false, }; } const prioritized = prioritizeFindings(findings); const findingsToVerify = prioritized.slice(0, 250); const skippedFindings = prioritized.slice(250); const batchSize = 50; return { findingsToVerify, skippedFindings, batchSize, estimatedRequests: Math.ceil(findingsToVerify.length / batchSize), isLimited: true, }; } function prioritizeFindings(findings: CodeFinding[]): CodeFinding[] { return [...findings].sort( (a, b) => (SEVERITY_PRIORITY[a.severity] ?? SEVERITY_PRIORITY.UNDEFINED) - (SEVERITY_PRIORITY[b.severity] ?? SEVERITY_PRIORITY.UNDEFINED), ); } async function verifyBatchWithRetry( batch: CodeFinding[], auth: ReturnType, ): Promise { let lastError: Error | null = null; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { try { const result = await requestBatchVerification(batch, auth); return result; } catch (error) { lastError = error instanceof Error ? error : new Error('Unknown verification error'); if (attempt < MAX_ATTEMPTS) { await sleep(RETRY_DELAYS_MS[attempt - 1] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]); } } } if (lastError !== null) { console.warn(` ⚠️ Qwen verification failed after ${MAX_ATTEMPTS} attempts: ${lastError.message}`); } return batch.map(() => buildUnverifiedAnalysis()); } async function requestBatchVerification( batch: CodeFinding[], auth: ReturnType, ): Promise { const userPrompt = truncateFromMiddle(buildVerificationPrompt(batch), MAX_PROMPT_CHARS); const response = await fetch(`${auth.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.apiKey}`, }, body: JSON.stringify({ model: auth.model, temperature: 0.1, max_tokens: 4096, stream: false, messages: [ { role: 'system', content: VERIFIER_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Qwen verification request failed with ${response.status}: ${errorText}`); } const payload = (await response.json()) as QwenChatCompletionResponse; const content = payload.choices?.[0]?.message?.content; if (typeof content !== 'string' || content.trim().length === 0) { throw new Error('Qwen verification response does not contain message content'); } const parsed = parseQwenVerificationResponse(content); return mapQwenResponseToAnalysis(batch.length, parsed); } function parseQwenVerificationResponse(content: string): QwenVerificationItem[] { const trimmed = content.trim(); const fencedMatch = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); const candidate = fencedMatch?.[1]?.trim() ?? trimmed; let jsonText = candidate; if (!jsonText.startsWith('[') || !jsonText.endsWith(']')) { const arrayMatch = candidate.match(/\[[\s\S]*\]/); if (arrayMatch === null) { throw new Error('Qwen response is not a JSON array'); } jsonText = arrayMatch[0]; } let value: unknown; try { value = JSON.parse(jsonText); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; throw new Error(`Failed to parse Qwen JSON response: ${message}`); } if (!Array.isArray(value)) { throw new Error('Qwen response JSON root must be an array'); } const items: QwenVerificationItem[] = []; for (const entry of value) { if (!isRecord(entry)) { throw new Error('Qwen response entry must be an object'); } if (typeof entry.finding_index !== 'number' || !Number.isInteger(entry.finding_index)) { throw new Error('Qwen response entry has invalid finding_index'); } if (typeof entry.is_likely_fp !== 'boolean') { throw new Error('Qwen response entry has invalid is_likely_fp'); } if (typeof entry.confidence !== 'number' || !Number.isFinite(entry.confidence)) { throw new Error('Qwen response entry has invalid confidence'); } if (entry.reason !== null && typeof entry.reason !== 'string') { throw new Error('Qwen response entry has invalid reason'); } if (typeof entry.explanation !== 'string') { throw new Error('Qwen response entry has invalid explanation'); } items.push({ finding_index: entry.finding_index, is_likely_fp: entry.is_likely_fp, confidence: entry.confidence, reason: entry.reason, explanation: entry.explanation, }); } return items; } function mapQwenResponseToAnalysis(expectedSize: number, items: QwenVerificationItem[]): RawFalsePositiveAnalysis[] { const byIndex = new Map(); for (const item of items) { if (item.finding_index < 0 || item.finding_index >= expectedSize) { throw new Error(`Qwen response contains out-of-range finding_index: ${item.finding_index}`); } if (byIndex.has(item.finding_index)) { throw new Error(`Qwen response contains duplicate finding_index: ${item.finding_index}`); } byIndex.set(item.finding_index, item); } if (byIndex.size !== expectedSize) { throw new Error(`Qwen response item count mismatch: expected ${expectedSize}, got ${byIndex.size}`); } const normalized: RawFalsePositiveAnalysis[] = []; for (let i = 0; i < expectedSize; i += 1) { const item = byIndex.get(i); if (item === undefined) { throw new Error(`Qwen response is missing finding_index ${i}`); } normalized.push({ is_likely_fp: item.is_likely_fp, confidence: clampConfidence(item.confidence), reason: item.reason, explanation: item.explanation, }); } return normalized; } function mapBatchToVerifiedFindings( batch: CodeFinding[], analysis: RawFalsePositiveAnalysis[], ): VerifiedFinding[] { return batch.map((finding, index) => { const fpAnalysis = analysis[index] ?? buildUnverifiedAnalysis(); return { ...finding, qwen_verified: fpAnalysis.is_likely_fp === false && fpAnalysis.reason !== 'verification_failed', fp_analysis: fpAnalysis, }; }); } function buildUnverifiedAnalysis(): RawFalsePositiveAnalysis { return { is_likely_fp: false, confidence: 0, reason: 'verification_failed', explanation: 'Qwen verification failed after retries.', }; } function buildQuotaSkippedFinding(finding: CodeFinding): VerifiedFinding { return { ...finding, qwen_verified: false, fp_analysis: { is_likely_fp: false, confidence: 0, reason: 'verification_skipped_quota', explanation: 'Skipped due to large project size. Use --verify-all to verify.', }, }; } function clampConfidence(value: number): number { if (value < 0) { return 0; } if (value > 100) { return 100; } return Math.round(value); } function truncateFromMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) { return value; } const marker = '\n...[TRUNCATED]...\n'; if (maxChars <= marker.length + 2) { return value.slice(0, maxChars); } const budget = maxChars - marker.length; const startLength = Math.ceil(budget / 2); const endLength = Math.floor(budget / 2); return `${value.slice(0, startLength)}${marker}${value.slice(value.length - endLength)}`; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }