/** * status.ts — readable run-state formatter. * * {@link formatRunStatus} turns a `RunState` into a plain line list covering * the run-level summary and one block per registered check: overall status, the * per-phase breakdown, captured findings/changes artifacts, and any errors. * It is a *pure* function of state — no disk I/O — so it is trivially * unit-testable and deterministic; the in-memory run-state is the single source * of truth for progress (the check-runner records findings/changes text on it). * * `formatRunStatus` is the single helper the `/pygienium-status` command uses; * keeping it here (out of `commands.ts`) lets `commands.ts` stay a thin binder. * * @module pygienium/status */ import type { CheckRun, RunState } from "./run-state.js"; const PHASE_ORDER = ["recon", "analysis", "fix", "verify", "cleanup"] as const; function toISO(ms: number | undefined): string { return ms == null ? "—" : new Date(ms).toISOString(); } function short(status: string): string { return status[0]?.toUpperCase() ?? "?"; } /** Count non-empty lines in captured findings/changes text. */ function lineCount(text: string | undefined): number { if (!text) return 0; const count = text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; return count; } /** * Format a single check block (without a trailing separator) — exposed so tests * and the status command share one rendering path. */ export function formatCheckBlock(check: CheckRun, indent = " "): string[] { const lines: string[] = []; const flag = check.fix ? " (--fix)" : ""; lines.push(`${indent}${check.name} — ${check.status}${flag}`); const phaseSummary = check.phases .map((p) => `${p.id}:${p.status.startsWith("in_progress") ? "…" : short(p.status)}`) .join(" "); if (phaseSummary) lines.push(`${indent} phases: ${phaseSummary}`); const findingsLines = lineCount(check.findings); if (findingsLines > 0) { lines.push(`${indent} findings: ${findingsLines} line(s)`); } const changesLines = lineCount(check.changes); if (changesLines > 0) { lines.push(`${indent} changes: ${changesLines} line(s)`); } if (check.error) { lines.push(`${indent} error: ${check.error}`); } for (const phase of check.phases) { if (phase.status === "failed" && phase.error && phase.error !== check.error) { lines.push(`${indent} ${phase.id}: ${phase.error}`); } } return lines; } /** * Build the `/pygienium-status` line list for a run state. Pure: no disk reads. * Layout: * * pygienium run — * started: * updated: * cwd: * recon: complete|pending * * checks (N): * * phases: recon:✓ analysis:✓ [fix:✓] verify:✓ cleanup:✓ * findings: line(s) * changes: line(s) * error: */ export function formatRunStatus(state: RunState): string[] { const checks = Object.values(state.checks); const lines: string[] = []; lines.push(`pygienium run — ${state.status}`); lines.push(` started: ${toISO(state.startedAt)}`); lines.push(` updated: ${toISO(state.updatedAt)}`); lines.push(` cwd: ${state.cwd}`); const reconLabel = state.recon.complete ? "complete" : "pending"; const reconTime = state.recon.finishedAt ? ` (${toISO(state.recon.finishedAt)})` : ""; lines.push(` recon: ${reconLabel}${reconTime}`); lines.push(""); lines.push(` checks (${checks.length}):`); if (checks.length === 0) { lines.push(" (none registered in this run state)"); } for (const check of checks) { lines.push(...formatCheckBlock(check)); } return lines; }