import { execFile } from "node:child_process"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import type { ReviewComment } from "./comments"; import type { AgentResponse } from "./conversations"; import type { ReviewReport } from "./review-report"; const execFileAsync = promisify(execFile); /** * On-disk review state (ADR-0005): user comments survive host restarts, and * lastReviewedHead powers the "Δ since last review" delta item. Lives inside * the git dir so it never pollutes the working tree and works in worktrees. */ export interface ReviewState { version: 3; /** All user comments, including resolved ones. */ comments: ReviewComment[]; /** Append-only agent responses attached to stable user conversation ids. */ agentResponses: AgentResponse[]; /** HEAD recorded when the last review session closed. */ lastReviewedHead?: string; /** Last structured reviewer output, including finding lifecycle and coverage. */ lastReport?: ReviewReport; } export function emptyReviewState(): ReviewState { return { version: 3, comments: [], agentResponses: [] }; } export function statePathFor(gitDir: string): string { return join(gitDir, "code-eye", "state.json"); } function isStoredReviewReport(value: unknown): value is ReviewReport { if (!value || typeof value !== "object") return false; const report = value as Record; if ((report.mode !== "quick" && report.mode !== "deep") || typeof report.summary !== "string") return false; if (typeof report.generatedAt !== "string" || !Array.isArray(report.findings) || !Array.isArray(report.checks)) { return false; } if (!report.coverage || typeof report.coverage !== "object") return false; const coverage = report.coverage as Record; if (!Array.isArray(coverage.reviewedFiles) || !coverage.reviewedFiles.every((file) => typeof file === "string")) { return false; } if ( !Array.isArray(coverage.skipped) || !coverage.skipped.every( (entry) => entry && typeof entry === "object" && typeof (entry as Record).file === "string" && typeof (entry as Record).reason === "string", ) ) { return false; } if ( !report.checks.every( (check) => check && typeof check === "object" && typeof (check as Record).command === "string" && ["passed", "failed", "skipped"].includes(String((check as Record).status)) && typeof (check as Record).summary === "string", ) ) { return false; } return report.findings.every((finding) => { if (!finding || typeof finding !== "object") return false; const item = finding as Record; const location = item.location as Record | undefined; return ( typeof item.id === "string" && typeof item.fingerprint === "string" && typeof item.identity === "string" && typeof item.title === "string" && typeof item.claim === "string" && typeof item.impact === "string" && typeof item.evidence === "string" && typeof item.category === "string" && ["critical", "high", "medium", "low", "info"].includes(String(item.severity)) && ["open", "fixed", "dismissed"].includes(String(item.status)) && typeof item.confidence === "number" && typeof item.firstSeenAt === "string" && typeof item.lastSeenAt === "string" && !!location && typeof location.file === "string" && typeof location.line === "number" && (location.side === undefined || location.side === "new" || location.side === "old") ); }); } function isStoredAgentResponse(value: unknown): value is AgentResponse { if (!value || typeof value !== "object") return false; const response = value as Record; if ( typeof response.id !== "string" || typeof response.commentId !== "string" || typeof response.summary !== "string" || typeof response.createdAt !== "string" || !Array.isArray(response.changedLocations) || !Array.isArray(response.checks) ) { return false; } const locationsValid = response.changedLocations.every((value) => { if (!value || typeof value !== "object") return false; const location = value as Record; return ( typeof location.file === "string" && (location.line === undefined || (typeof location.line === "number" && Number.isInteger(location.line) && location.line > 0)) && (location.before === undefined || typeof location.before === "string") && (location.after === undefined || typeof location.after === "string") ); }); const checksValid = response.checks.every((value) => { if (!value || typeof value !== "object") return false; const check = value as Record; return ( typeof check.command === "string" && ["passed", "failed", "skipped"].includes(String(check.status)) && typeof check.summary === "string" ); }); return locationsValid && checksValid; } /** Tolerates missing/corrupt files — a broken state file just starts fresh. */ export async function loadReviewStateFrom(path: string): Promise { try { const data: unknown = JSON.parse(await readFile(path, "utf8")); if (!data || typeof data !== "object") return emptyReviewState(); const { comments, agentResponses, lastReviewedHead, lastReport } = data as Record; return { version: 3, comments: Array.isArray(comments) ? (comments as ReviewComment[]) : [], agentResponses: Array.isArray(agentResponses) && agentResponses.every(isStoredAgentResponse) ? agentResponses : [], ...(typeof lastReviewedHead === "string" ? { lastReviewedHead } : {}), ...(isStoredReviewReport(lastReport) ? { lastReport } : {}), }; } catch { return emptyReviewState(); } } export async function saveReviewStateTo(path: string, state: ReviewState): Promise { await mkdir(dirname(path), { recursive: true }); const tmp = `${path}.tmp`; await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); await rename(tmp, path); } async function gitDir(cwd: string): Promise { const { stdout } = await execFileAsync("git", ["rev-parse", "--absolute-git-dir"], { cwd }); return stdout.trim(); } /** Load state for a repo; never throws (falls back to empty outside a repo). */ export async function loadReviewState(cwd: string): Promise { try { return await loadReviewStateFrom(statePathFor(await gitDir(cwd))); } catch { return emptyReviewState(); } } /** Persist state for a repo; best-effort — a failed save must not break review close. */ export async function saveReviewState(cwd: string, state: ReviewState): Promise { try { await saveReviewStateTo(statePathFor(await gitDir(cwd)), state); } catch { // best-effort } }