464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
/**
|
|
* 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 `<cwd>/.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 `<cwd>/.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=<a>,<b>` 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/<name>/`).
|
|
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<AllRunOutcome> {
|
|
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] <check-label>: <phase>` so every check name is
|
|
* surfaced in the strip over the course of the run.
|
|
*/
|
|
function setAllPhase(
|
|
strip: ReturnType<typeof createPhaseStrip>,
|
|
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<string, string> = {
|
|
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<string> {
|
|
const path = allSummaryPath(state.cwd);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, renderAllSummary(state, selected), "utf8");
|
|
return path;
|
|
}
|