| 1 | import type { Translator } from "./i18n"; |
| 2 | import type { TurnChanges, WireCompletionReceipt, WireCompletionSummary } from "./types"; |
| 3 | |
| 4 | const validCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; |
| 5 | |
| 6 | export function normalizeTurnChanges(value?: TurnChanges): TurnChanges | undefined { |
| 7 | if (!value) return undefined; |
| 8 | const valid = Array.isArray(value.files) && validCount(value.added) && validCount(value.removed) && validCount(value.turn); |
| 9 | const files = (Array.isArray(value.files) ? value.files : []).filter(file => file && typeof file.path === "string" && validCount(file.added) && validCount(file.removed)); |
| 10 | return { |
| 11 | id: typeof value.id === "string" ? value.id : undefined, |
| 12 | turn: validCount(value.turn) ? value.turn : 0, |
| 13 | coverage: valid && files.length === value.files.length && (value.coverage === "complete" || value.coverage === "partial") ? value.coverage : "unknown", |
| 14 | files, |
| 15 | added: validCount(value.added) ? value.added : 0, |
| 16 | removed: validCount(value.removed) ? value.removed : 0, |
| 17 | reasons: (Array.isArray(value.reasons) ? value.reasons : []).filter((v): v is string => typeof v === "string"), |
| 18 | }; |
| 19 | } |
| 20 | |
| 21 | export function normalizeCompletionReceipt(value?: WireCompletionReceipt): WireCompletionReceipt | undefined { |
| 22 | if (!value) return undefined; |
| 23 | return { |
| 24 | verdict: String(value.verdict ?? "unknown"), |
| 25 | assessmentKind: value.assessmentKind === "facts" ? "facts" : undefined, |
| 26 | diff: normalizeTurnChanges(value.diff), |
| 27 | interrupted: value.interrupted === true, |
| 28 | changes: Array.isArray(value.changes) ? value.changes : [], |
| 29 | verifications: (Array.isArray(value.verifications) ? value.verifications : []).filter(v => v && typeof v.command === "string").map(v => ({ |
| 30 | ...v, passed: v.passed === true && (v.exitCode === undefined || v.exitCode === 0), stale: v.stale === true, |
| 31 | })), |
| 32 | gaps: (Array.isArray(value.gaps) ? value.gaps : []).filter(g => g && typeof g.kind === "string"), |
| 33 | risks: (Array.isArray(value.risks) ? value.risks : []).filter((v): v is string => typeof v === "string"), |
| 34 | }; |
| 35 | } |
| 36 | |
| 37 | export function mergeTurnResult( |
| 38 | summary?: WireCompletionSummary, |
| 39 | receipt?: WireCompletionReceipt, |
| 40 | turnId?: string, |
| 41 | checkpointTurn?: number, |
| 42 | ): WireCompletionSummary { |
| 43 | const normalized = normalizeCompletionReceipt(receipt); |
| 44 | return { |
| 45 | preset: "", verdict: normalized?.verdict ?? "unknown", mutations: 0, |
| 46 | checks_passed: 0, checks_failed: 0, checks_suppressed: 0, review: "none", constraint_degraded: false, |
| 47 | ...summary, |
| 48 | receipt: normalized ?? summary?.receipt, |
| 49 | turnId: turnId ?? summary?.turnId, |
| 50 | checkpointTurn: validCount(checkpointTurn) ? checkpointTurn : summary?.checkpointTurn, |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | export function turnResultHasContent(summary: WireCompletionSummary): boolean { |
| 55 | const receipt = summary.receipt; |
| 56 | return Boolean(summary.checking || summary.mutations > 0 || summary.checks_failed > 0 || summary.checks_passed > 0 || summary.checks_suppressed > 0 || summary.attention |
| 57 | || receipt?.diff?.files.length || receipt?.diff?.reasons.length || receipt?.changes?.length || receipt?.verifications?.length || receipt?.gaps?.length || receipt?.risks?.length); |
| 58 | } |
| 59 | |
| 60 | export function turnChangeText(summary: WireCompletionSummary, t: Translator): string { |
| 61 | const diff = summary.receipt?.diff; |
| 62 | if (!diff || diff.coverage === "unknown") return t("completion.changesUnknown"); |
| 63 | if (diff.files.length === 0) return t(diff.coverage === "complete" ? "completion.noNetChanges" : "completion.changesIncomplete"); |
| 64 | const files = t(diff.coverage === "complete" ? "completion.filesChanged" : "completion.filesCounted", { count: diff.files.length }); |
| 65 | const suffix = diff.coverage === "partial" ? ` · ${t("completion.partialStats")}` : ""; |
| 66 | return `${files} · +${diff.added} −${diff.removed}${suffix}`; |
| 67 | } |
| 68 | |
| 69 | export type TurnCheckStatus = "running" | "failed" | "interrupted" | "stale" | "passed" | "none" | "unknown"; |
| 70 | |
| 71 | export function turnCheckState(summary: WireCompletionSummary): { status: TurnCheckStatus; count: number; stale: boolean } { |
| 72 | const checks = summary.receipt?.verifications ?? []; |
| 73 | const stale = checks.some(v => v.stale) || (summary.gap_kinds ?? []).some(kind => kind === "stale_check" || kind === "stale_verification"); |
| 74 | const failed = summary.receipt ? checks.filter(v => !v.passed && !v.interrupted).length : summary.checks_failed; |
| 75 | const interrupted = checks.some(v => v.interrupted) || Boolean(summary.receipt?.interrupted && summary.liveChecks?.length); |
| 76 | const status: TurnCheckStatus = summary.checking ? "running" |
| 77 | : failed > 0 ? "failed" |
| 78 | : interrupted ? "interrupted" |
| 79 | : stale ? "stale" |
| 80 | : !summary.receipt ? "unknown" |
| 81 | : checks.length > 0 ? "passed" : "none"; |
| 82 | return { status, count: status === "failed" ? failed : checks.length, stale }; |
| 83 | } |
| 84 | |
| 85 | export function turnCheckText(summary: WireCompletionSummary, t: Translator): string { |
| 86 | const { status, count, stale } = turnCheckState(summary); |
| 87 | const labels = { |
| 88 | running: "completion.checkRunning", failed: "completion.checkFailed", interrupted: "completion.checkInterrupted", |
| 89 | stale: "completion.checkStale", passed: "completion.checkPassed", none: "completion.checkNone", unknown: "completion.checkUnknown", |
| 90 | } as const; |
| 91 | const text = t(labels[status], { count }); |
| 92 | return status === "failed" && stale ? `${text} · ${t("completion.someStale")}` : text; |
| 93 | } |
| 94 |