/** * modes/all.ts — `/pygienium-all` master orchestrator. * * Runs every registered check in sequence as ordered phases under a unified * status strip, with resumable state and a final summary report. This is the * piolium "balanced"/"deep" mode analogue, but simpler: one run, sequential * phases, shared recon (no scheduler — checks run one after another). * * Pipeline: * init single run (mode "all") → run shared recon once → * for each registered check (in registry order): call `runCheck` with the * SHARED run-state record (not a fresh one per check) → reconcile run * status → write `.pygienium/all-summary.md`. * * Resumability: terminal checks (`complete`/`skipped`) are skipped on resume; * `in_progress`/`failed`/`pending` checks re-run. `--fresh` resets every check * entry and re-runs the lot. `--only=comments,complexity` narrows the candidate * set to a named subset (registration order preserved). * * @module pygienium/modes/all */ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { ExtensionUIContext } from "@oh-my-pi/pi-coding-agent"; import { getAllChecks, type CheckDefinition } from "../checks/registry.js"; import { runCheck } from "./check-runner.js"; import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent"; import { createPhaseStrip, type SendChatMessage } from "../phases.js"; import { createPipelineFooter, type ItemStatus } from "../footer.js"; import { runRecon } from "../recon.js"; import { applyPhaseStatus, ensureRunStateIgnored, initRunState, loadRunState, markRunStatus, PHASE_RECON, reconcileRunStatus, resetCheckEntry, saveRunState, shouldRunOnResume, stateDir, type RunState, } from "../run-state.js"; /** Artifact directory name (relative to cwd) that holds `all-summary.md`. */ export const ALL_ARTIFACT_DIR = ".pygienium"; /** Filename for the unified per-check summary report. */ export const ALL_SUMMARY_FILENAME = "all-summary.md"; /** Resolve `/.pygienium/all-summary.md`. */ export function allSummaryPath(cwd: string): string { return join(cwd, ALL_ARTIFACT_DIR, ALL_SUMMARY_FILENAME); } export interface AllRunOptions { /** Working directory (from `ctx.cwd`). */ cwd: string; /** Target path to scan (absolute; defaults to `cwd`). */ target?: string; /** Whether fixes should be applied (`--fix`). */ fix?: boolean; /** Subset of check names to run (`--only=comments,complexity`). */ only?: string[]; /** Reset and re-run every check, ignoring prior terminal state (`--fresh`). */ fresh?: boolean; /** * Ensure `/.gitignore` excludes `.pygienium/` before this run writes * state/artifacts (default true). Set false with `--no-gitignore`. */ gitignore?: boolean; /** UI context (optional; null in print mode). */ ui?: ExtensionUIContext; /** Whether dialog-capable UI is available. */ hasUI?: boolean; /** Optional callback to post completion messages into the chat. */ sendChatMessage?: SendChatMessage; /** Optional callback forwarding raw sub-agent events to the chat stream. */ onAgentEvent?: (phase: string, event: AgentSessionEvent) => void; /** Optional callback to emit synthetic progress lines during non-agent * phases (verify, cleanup, recon) into the chat stream. */ sendPhaseLine?: (phase: string, text: string) => void; } /** Outcome of {@link runAllChecks}. */ export interface AllRunOutcome { /** Final run status. */ status: RunState["status"]; /** The updated run state. */ state: RunState; /** Absolute path the summary was written to. */ summaryPath: string; /** Checks that were actually dispatched (ran `runCheck`). */ ran: string[]; /** Checks skipped because they were already terminal. */ skipped: string[]; /** True when this run appended `.pygienium/` to the repo's .gitignore. */ gitignoreAppended?: boolean; } /** * Filter the registry to the `only` subset, preserving insertion order. * Unknown names are silently dropped (a typo shouldn't abort an all-run). */ export function selectChecks(only?: string[]): CheckDefinition[] { const all = getAllChecks(); if (!only || only.length === 0) return all; const set = new Set(only); return all.filter((c) => set.has(c.name)); } /** * Parse `/pygienium-all` args: an optional `[path]` positional plus the * `--fix`, `--fresh`, and `--only=,` flags. */ export function parseAllArgs( args: string, cwd: string, ): { target: string; fix: boolean; fresh: boolean; gitignore: boolean; only: string[]; } { const tokens = args.trim().length > 0 ? args.trim().split(/\s+/) : []; let fix = false; let fresh = false; let gitignore = true; let target = cwd; const only: string[] = []; for (const tok of tokens) { if (tok === "--fix") { fix = true; } else if (tok === "--fresh") { fresh = true; } else if (tok === "--no-gitignore") { gitignore = false; } else if (tok.startsWith("--only=")) { for (const name of tok.slice("--only=".length).split(",")) { const trimmed = name.trim(); if (trimmed) only.push(trimmed); } } else if (!tok.startsWith("--")) { target = tok; } } return { target: resolve(cwd, target), fix, fresh, gitignore, only }; } /** Count non-empty lines in captured findings/changes text. */ function lineCount(text: string | undefined): number { if (!text) return 0; return text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; } function toISO(ms: number | undefined): string { return ms == null ? "—" : new Date(ms).toISOString(); } function short(status: string): string { return status[0]?.toUpperCase() ?? "?"; } /** * Render the unified summary markdown from run-state. Lists per-check * outcomes: status, artifact paths (when present on disk), findings/changes * line counts, phase breakdown, and errors. */ export function renderAllSummary( state: RunState, selected: CheckDefinition[], ): string { const lines: string[] = []; lines.push("# Pygienium all-run summary"); lines.push(""); lines.push(`- status: ${state.status}`); lines.push(`- started: ${toISO(state.startedAt)}`); lines.push(`- updated: ${toISO(state.updatedAt)}`); lines.push(`- cwd: ${state.cwd}`); lines.push(`- recon: ${state.recon.complete ? "complete" : "pending"}`); lines.push(`- checks: ${selected.length}`); lines.push(""); for (const check of selected) { const entry = state.checks[check.name]; const fixTag = entry?.fix ? " (--fix)" : ""; lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`); lines.push(""); // Errors are terminal-run facts: a check that completed via a later // resume/retry must not surface a stale error under a "complete" // status (markCheckStatus clears it on success; this guard covers // hand-edited or legacy state files too). if (entry?.error && entry?.status !== "complete") { lines.push(`- error: ${entry.error}`); } // Artifact paths (canonical root: `.pygienium/checks//`). const findingsPath = `${state.cwd}/.pygienium/checks/${check.name}/findings.md`; const changesPath = `${state.cwd}/.pygienium/checks/${check.name}/changes.md`; const fLines = lineCount(entry?.findings); const cLines = lineCount(entry?.changes); if (fLines > 0) { lines.push(`- findings: ${findingsPath} (${fLines} line(s))`); } if (cLines > 0) { lines.push(`- changes: ${changesPath} (${cLines} line(s))`); } // Phase breakdown for transparency. if (entry?.phases?.length) { const phaseSummary = entry.phases .map( (p) => `${p.id}:${ p.status.startsWith("in_progress") ? "…" : short(p.status) }`, ) .join(" "); lines.push(`- phases: ${phaseSummary}`); } lines.push(""); } return lines.join("\n") + "\n"; } /** * Run every registered check in sequence under a unified status strip. * * Resumable: terminal checks skip on resume unless `fresh` resets them. */ export async function runAllChecks( opts: AllRunOptions, ): Promise { const { cwd } = opts; const target = opts.target ?? cwd; const fix = opts.fix ?? false; const fresh = opts.fresh ?? false; const hasUI = opts.hasUI ?? false; // Keep pygienium's own output out of the scanned repo's git index unless // the caller opted out with --no-gitignore. const gitignoreAppended = opts.gitignore === false ? false : await ensureRunStateIgnored(cwd); const selected = selectChecks(opts.only); if (selected.length === 0) { // `--only` selected nothing (or no checks registered). Still produce a // summary so the caller has an artifact. const state = (await loadRunState(cwd)) ?? initRunState(cwd, []); markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); const summaryPath = await writeAllSummary(state, []); return { status: state.status, state, summaryPath, ran: [], skipped: [], gitignoreAppended, }; } // --- Init / resume the single shared run-state -------------------------- let state = (await loadRunState(cwd)) ?? initRunState( cwd, selected.map((c) => ({ name: c.name, label: c.label, fix })), ); // Ensure every selected check has an entry (adds any missing on resume). for (const check of selected) { if (!state.checks[check.name]) { state.checks[check.name] = { name: check.name, label: check.label, status: "pending", fix, phases: [ { id: "recon", status: "pending" }, { id: "analysis", status: "pending" }, ...(fix ? [{ id: "fix", status: "pending" as const }] : []), { id: "verify", status: "pending" }, { id: "cleanup", status: "pending" }, ], }; } } await saveRunState(state); // --- Unified phase strip logging all check names ----------------------- const strip = createPhaseStrip({ ui: opts.ui, hasUI, }); setAllPhase(strip, selected, 0, "recon"); // Pipeline-overview footer: a multi-line widget listing every check with the // cursor on the active one and what's to come. Detail lives in the chat // (per-check completion trees); the footer is the overview. Inner // `runCheck` calls pass `footer: false` so two overviews never compete. const allFooter = createPipelineFooter({ ui: opts.ui, hasUI, statusKey: "pygienium-all", }); const allItems = selected.map((c) => ({ label: c.label, status: "pending" as ItemStatus, })); allFooter.setPipeline("pygienium: all", allItems, 0); const footerSettle = (name: string, status: ItemStatus): void => { const idx = selected.findIndex((c) => c.name === name); if (idx >= 0) allFooter.setItem(idx, status); }; // --- Shared recon (run once before any check) --------------------------- if (!state.recon.complete) { const snapshot = await runRecon(cwd); state.recon = { complete: true, path: join(stateDir(cwd), "recon.json"), finishedAt: snapshot.createdAt, }; // Mark recon complete for every selected check that hasn't run it yet. for (const check of selected) { applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); } await saveRunState(state); } const ran: string[] = []; const skipped: string[] = []; // --- Per-check loop (shared run-state, registry order) ------------------ for (let i = 0; i < selected.length; i++) { const check = selected[i]!; setAllPhase(strip, selected, i, "analysis"); allFooter.setCursor(i); const entry = state.checks[check.name]; // Resumability: skip terminal checks unless --fresh. if (entry && !shouldRunOnResume(entry, fresh)) { skipped.push(check.name); footerSettle( check.name, entry.status === "complete" ? "skipped" : "skipped", ); strip.log( `pygienium: ${check.label} — already ${entry.status}, skipping`, ); continue; } if (fresh && entry) { resetCheckEntry(state, check.name, fix); await saveRunState(state); } strip.log(`pygienium: running ${check.label}…`); const outcome = await runCheck({ check, cwd, scope: { cwd, target, fix, rest: [] }, ui: opts.ui, hasUI, existingState: state, sendChatMessage: opts.sendChatMessage, onAgentEvent: opts.onAgentEvent, sendPhaseLine: opts.sendPhaseLine, // The all-run footer already owns the pipeline-overview widget // slot; suppress the per-check footer so two overviews never // compete over the same `belowEditor` area. footer: false, // Inner runs must not prime the .gitignore twice — the outer all-run // already ensured it. (ensureRunStateIgnored is memoized per cwd, // so this is belt-and-braces.) gitignore: opts.gitignore, }); state = outcome.state; ran.push(check.name); footerSettle( check.name, outcome.status === "complete" ? "complete" : outcome.status === "skipped" ? "skipped" : "failed", ); strip.log(`pygienium ${check.label}: ${outcome.status}`); } // --- Finalize ----------------------------------------------------------- markRunStatus(state, reconcileRunStatus(state)); await saveRunState(state); setAllPhase(strip, selected, selected.length - 1, "cleanup"); // Mark the final check's footer status terminal; the cursor started at 0 // and the loop advanced it, so the last selected item is the live one. if (selected.length > 0) { footerSettle( selected[selected.length - 1]!.name, state.checks[selected[selected.length - 1]!.name]?.status === "failed" ? "failed" : "complete", ); } strip.done(); allFooter.done(); const summaryPath = await writeAllSummary(state, selected); return { status: state.status, state, summaryPath, ran, skipped, }; } /** * Update the unified strip to reflect which check is active and its phase. * Renders `pygienium: all [i/N] : ` so every check name is * surfaced in the strip over the course of the run. */ function setAllPhase( strip: ReturnType, selected: CheckDefinition[], index: number, phaseId: string, ): void { const check = selected[index]; const label = check?.label ?? "(none)"; const total = selected.length; const pos = String(index + 1); const phaseLabel = PHASE_ALL_LABELS[phaseId] ?? phaseId; strip.setPhase(`all [${pos}/${total}] ${label}: ${phaseLabel}`); } const PHASE_ALL_LABELS: Record = { recon: "Recon", analysis: "Scanning", fix: "Fixing", verify: "Verifying", cleanup: "Done", }; /** Write the all-summary.md report, creating the directory as needed. */ async function writeAllSummary( state: RunState, selected: CheckDefinition[], ): Promise { const path = allSummaryPath(state.cwd); await mkdir(dirname(path), { recursive: true }); await writeFile(path, renderAllSummary(state, selected), "utf8"); return path; }