import { CATEGORY_PRIORITY, SEVERITY_ORDER, type ChangeKind, type DedupFindingsResult, type Finding, type FindingCategory, type FindingSeverity, } from "./types.ts"; function priorityIndex(values: T, value: string): number { const index = values.indexOf(value as T[number]); return index === -1 ? values.length : index; } const CHANGE_KIND_ORDER = ["signature", "behavior", "internal", "cosmetic"] as const; function highestSeverity(a: FindingSeverity, b: FindingSeverity): FindingSeverity { return priorityIndex(SEVERITY_ORDER, b) < priorityIndex(SEVERITY_ORDER, a) ? b : a; } function highestChangeKind(a: ChangeKind | undefined, b: ChangeKind | undefined): ChangeKind | undefined { if (!a) return b; if (!b) return a; return priorityIndex(CHANGE_KIND_ORDER, b) < priorityIndex(CHANGE_KIND_ORDER, a) ? b : a; } function clearerText(current: string, candidate: string): string { const trimmed = candidate.trim(); if (!trimmed) return current; if (!current) return trimmed; return trimmed.length > current.length ? trimmed : current; } export function normalizeFindingText(value: unknown, maxLength?: number): string { if (typeof value !== "string") return ""; const normalized = value.replace(/[\u0000-\u001F\u007F]/g, " ").trim(); if (!normalized) return ""; return maxLength ? normalized.slice(0, maxLength) : normalized; } /** Like normalizeFindingText, but preserves line structure for document-sized markdown blocks. */ export function normalizeMultilineText(value: unknown): string { if (typeof value !== "string") return ""; return value .replace(/\r\n?/g, "\n") .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ") .trim(); } export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } export function coerceFinding(raw: unknown, assignedReviewer?: string): Finding | null { if (!isRecord(raw)) return null; if (raw.findings === 0) return null; // The assigned persona is ground truth; self-reported names are hallucination-prone. const reviewer = normalizeFindingText(assignedReviewer) || normalizeFindingText(raw.reviewer); const file = normalizeFindingText(raw.file); const category = normalizeFindingText(raw.category).toLowerCase(); const severity = normalizeFindingText(raw.severity).toLowerCase(); const rationale = normalizeFindingText(raw.rationale); const suggestedChange = normalizeFindingText(raw.suggestedChange, 2000); const changeKind = normalizeFindingText(raw.changeKind).toLowerCase(); if (!reviewer || !file || !rationale || !suggestedChange) { return null; } if (priorityIndex(CATEGORY_PRIORITY, category) === CATEGORY_PRIORITY.length) return null; if (priorityIndex(SEVERITY_ORDER, severity) === SEVERITY_ORDER.length) return null; const numericLine = typeof raw.line === "number" ? raw.line : Number(raw.line); if (!Number.isInteger(numericLine) || (numericLine !== -1 && numericLine <= 0)) return null; return { reviewer, file, line: numericLine, category: category as FindingCategory, severity: severity as FindingSeverity, rationale, suggestedChange, ...(priorityIndex(CHANGE_KIND_ORDER, changeKind) < CHANGE_KIND_ORDER.length ? { changeKind: changeKind as ChangeKind } : {}), }; } interface Group { finding: Finding; reviewerSet: Set; count: number; } export function dedupFindings(rawFindings: unknown[]): DedupFindingsResult { const groups = new Map(); let inputCount = 0; for (const raw of rawFindings) { const finding = coerceFinding(raw); if (!finding) continue; inputCount++; const key = `${finding.file}\u0000${finding.line}\u0000${finding.category}`; const group = groups.get(key); if (!group) { groups.set(key, { finding: { ...finding }, reviewerSet: new Set([finding.reviewer]), count: 1, }); continue; } group.count++; group.reviewerSet.add(finding.reviewer); group.finding.severity = highestSeverity(group.finding.severity, finding.severity); group.finding.changeKind = highestChangeKind(group.finding.changeKind, finding.changeKind); group.finding.rationale = clearerText(group.finding.rationale, finding.rationale); group.finding.suggestedChange = clearerText(group.finding.suggestedChange, finding.suggestedChange); } const findings = [...groups.values()] .map((group) => ({ ...group.finding, reviewer: [...group.reviewerSet].join(", ") })) .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || priorityIndex(CATEGORY_PRIORITY, a.category) - priorityIndex(CATEGORY_PRIORITY, b.category) || priorityIndex(SEVERITY_ORDER, a.severity) - priorityIndex(SEVERITY_ORDER, b.severity) ); return { findings, inputCount, outputCount: findings.length, duplicateGroups: [...groups.values()].filter((group) => group.count > 1).length, }; }