import { createHash } from "node:crypto"; import { findCommentLineIndex, type ReviewComment } from "./comments"; import type { CommitEntry } from "./git"; import { shaMatches } from "./locate"; import { parseUnifiedDiff, type DiffLine } from "./parse-unidiff"; import type { WalkthroughStop } from "./walkthrough"; export type ReviewMode = "quick" | "deep"; export type FindingSeverity = "critical" | "high" | "medium" | "low" | "info"; export type FindingStatus = "open" | "fixed" | "dismissed"; export interface FindingLocation { sha?: string | null; file: string; line: number; side?: "new" | "old"; } export interface ReviewFindingInput { /** Stable semantic slug, e.g. "retry-loop:exhaustion-drops-final-error". */ identity: string; title: string; claim: string; impact: string; evidence: string; severity: FindingSeverity; category: string; /** Reviewer confidence from 0 to 1. */ confidence: number; location: FindingLocation; suggestion?: string; } export interface ReviewFinding extends ReviewFindingInput { id: string; fingerprint: string; status: FindingStatus; firstSeenAt: string; lastSeenAt: string; } export interface ReviewCheck { command: string; status: "passed" | "failed" | "skipped"; summary: string; } export interface ReviewCoverage { reviewedFiles: string[]; skipped: Array<{ file: string; reason: string }>; } export interface ReviewReportInput { mode: ReviewMode; summary: string; findings: ReviewFindingInput[]; checks: ReviewCheck[]; coverage: ReviewCoverage; } export interface ReviewReport extends Omit { findings: ReviewFinding[]; generatedAt: string; } function normalizeIdentity(value: string): string { return value.toLowerCase().replace(/\s+/g, " ").trim(); } export function findingFingerprint(finding: ReviewFindingInput): string { const identity = [ normalizeIdentity(finding.category), normalizeIdentity(finding.location.file), normalizeIdentity(finding.identity), ].join("\0"); return createHash("sha256").update(identity).digest("hex"); } export function validateReviewReport(report: ReviewReportInput): string[] { const errors: string[] = []; const seenFingerprints = new Map(); report.findings.forEach((finding, index) => { const prefix = `finding ${index + 1}`; for (const [field, value] of [ ["identity", finding.identity], ["title", finding.title], ["claim", finding.claim], ["impact", finding.impact], ["evidence", finding.evidence], ["category", finding.category], ["file", finding.location.file], ] as const) { if (!value.trim()) errors.push(`${prefix}: ${field} is required`); } if (!Number.isInteger(finding.location.line) || finding.location.line < 1) { errors.push(`${prefix}: line must be a positive integer`); } if (!Number.isFinite(finding.confidence) || finding.confidence < 0 || finding.confidence > 1) { errors.push(`${prefix}: confidence must be between 0 and 1`); } const fingerprint = findingFingerprint(finding); const duplicateOf = seenFingerprints.get(fingerprint); if (duplicateOf !== undefined) { errors.push(`${prefix}: duplicate finding identity (same as finding ${duplicateOf + 1})`); } else { seenFingerprints.set(fingerprint, index); } }); return errors; } export function reconcileReviewReport( report: ReviewReportInput, previous: ReviewFinding[], generatedAt = new Date().toISOString(), ): ReviewReport { const errors = validateReviewReport(report); if (errors.length > 0) { throw new Error(`Invalid review report: ${errors.join("; ")}`); } const priorByFingerprint = new Map(previous.map((finding) => [finding.fingerprint, finding])); const findings = report.findings.map((input): ReviewFinding => { const fingerprint = findingFingerprint(input); const prior = priorByFingerprint.get(fingerprint); return { ...input, location: { ...input.location, side: input.location.side ?? "new" }, id: prior?.id ?? `f_${fingerprint.slice(0, 16)}`, fingerprint, status: prior?.status === "dismissed" ? "dismissed" : "open", firstSeenAt: prior?.firstSeenAt ?? generatedAt, lastSeenAt: generatedAt, }; }); const reported = new Set(findings.map((finding) => finding.fingerprint)); const reviewedFiles = new Set(report.coverage.reviewedFiles.map(normalizeIdentity)); for (const prior of previous) { if (reported.has(prior.fingerprint)) continue; const wasReviewed = reviewedFiles.has(normalizeIdentity(prior.location.file)); findings.push({ ...prior, status: report.mode === "deep" && wasReviewed && prior.status === "open" ? "fixed" : prior.status, }); } return { ...report, findings, generatedAt }; } export function dismissReviewFindings(report: ReviewReport, ids: string[]): ReviewReport { const dismissed = new Set(ids); if (dismissed.size === 0) return report; return { ...report, findings: report.findings.map((finding) => dismissed.has(finding.id) ? { ...finding, status: "dismissed" } : finding, ), }; } export async function validateReviewFindingLocations( report: ReviewReport, items: CommitEntry[], loadDiffFor: (sha: string | null) => Promise, ): Promise { const errors: string[] = []; const cache = new Map(); for (const finding of report.findings) { if (finding.status !== "open") continue; const item = items.find((candidate) => shaMatches(candidate.sha, finding.location.sha)); if (!item) { errors.push(`${finding.location.file}:${finding.location.line} has no matching review item`); continue; } const key = item.sha ?? "working"; let lines = cache.get(key); if (!lines) { lines = parseUnifiedDiff(await loadDiffFor(item.sha)); cache.set(key, lines); } const anchor: ReviewComment = { id: finding.id, author: "agent", sha: item.sha, file: finding.location.file, side: finding.location.side ?? "new", line: finding.location.line, body: finding.claim, }; if (findCommentLineIndex(lines, anchor) < 0) { errors.push(`${finding.location.file}:${finding.location.line} (${anchor.side}) is not present in the diff`); } } return errors; } export function reviewReportToPresentation( report: ReviewReport, items: CommitEntry[], ): { comments: ReviewComment[]; summaries: WalkthroughStop[] } { const rank: Record = { critical: 5, high: 4, medium: 3, low: 2, info: 1 }; const comments: ReviewComment[] = report.findings .filter((finding) => finding.status === "open") .sort((a, b) => rank[b.severity] - rank[a.severity] || b.confidence - a.confidence) .map((finding) => { const item = items.find((candidate) => shaMatches(candidate.sha, finding.location.sha)); return { id: finding.id, author: "agent", sha: item ? item.sha : (finding.location.sha ?? null), file: finding.location.file, side: finding.location.side ?? "new", line: finding.location.line, title: finding.title, body: finding.claim, kind: finding.category, severity: finding.severity, suggestion: finding.suggestion, impact: finding.impact, evidence: finding.evidence, confidence: finding.confidence, findingStatus: finding.status, fingerprint: finding.fingerprint, }; }); const open = report.findings.filter((finding) => finding.status === "open").length; const fixed = report.findings.filter((finding) => finding.status === "fixed").length; const summaries: WalkthroughStop[] = [ { title: `${report.mode === "deep" ? "Deep" : "Quick"} review`, detail: `${report.summary}\n\n${open} open finding(s) · ${fixed} fixed since prior review`, kind: "overview", }, ]; if (comments.length > 0) { summaries.push({ title: "Findings", detail: comments .map( (comment) => `${comment.severity?.toUpperCase()} · ${comment.file}:${comment.line} — ${comment.title ?? comment.body}`, ) .join("\n"), kind: "findings", }); } if (report.checks.length > 0) { summaries.push({ title: "Checks", detail: report.checks .map((check) => `${check.status.toUpperCase()} · ${check.command} — ${check.summary}`) .join("\n"), kind: "checks", }); } const reviewed = report.coverage.reviewedFiles.length > 0 ? `Reviewed: ${report.coverage.reviewedFiles.join(", ")}` : "Reviewed: no files reported"; const skipped = report.coverage.skipped.map((entry) => `${entry.file} — ${entry.reason}`); summaries.push({ title: "Coverage", detail: [reviewed, ...(skipped.length > 0 ? ["Skipped:", ...skipped] : [])].join("\n"), kind: "coverage", }); return { comments, summaries }; }