feat(run): resume-aware per-check runs, verify hooks, run-state hardening
/pygienium-<check> is now resume-aware (terminal checks skipped unless --fresh) and shares run-state with all/resume; every check gets a verify hook that fails loudly when a sub-agent returns ok with no artifact; run-state clears stale errors on retry success and reconciles a run as failed only when every check failed. Drops the superseded hygiene-state.ts model.
This commit is contained in:
@@ -270,6 +270,34 @@ async function complexityGate(cwd: string): Promise<string | undefined> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify hook: confirms the check actually produced its artifacts. After the
|
||||||
|
* scan phase `findings.md` must exist; after the fix phase `changes.md` must
|
||||||
|
* exist too. Without this, a sub-agent that returns empty/ok without writing
|
||||||
|
* its report would be stamped `complete` — a false positive. Mirrors
|
||||||
|
* {@link commentsVerify} / {@link todosVerify}.
|
||||||
|
*/
|
||||||
|
async function complexityVerify(
|
||||||
|
scope: CheckScope,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const { stat } = await import("node:fs/promises");
|
||||||
|
const f = findingsPath(scope);
|
||||||
|
try {
|
||||||
|
await stat(f);
|
||||||
|
} catch {
|
||||||
|
return `complexity verify: expected findings.md at ${f} after scan, none found.`;
|
||||||
|
}
|
||||||
|
if (scope.fix) {
|
||||||
|
const c = changesPath(scope);
|
||||||
|
try {
|
||||||
|
await stat(c);
|
||||||
|
} catch {
|
||||||
|
return `complexity verify: expected changes.md at ${c} after --fix, none found.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** The excessive complexity check definition. */
|
/** The excessive complexity check definition. */
|
||||||
export const complexityCheck = {
|
export const complexityCheck = {
|
||||||
name: "complexity",
|
name: "complexity",
|
||||||
@@ -282,6 +310,7 @@ export const complexityCheck = {
|
|||||||
buildScanTask: buildComplexityScanTask,
|
buildScanTask: buildComplexityScanTask,
|
||||||
buildFixTask: buildComplexityFixTask,
|
buildFixTask: buildComplexityFixTask,
|
||||||
gate: complexityGate,
|
gate: complexityGate,
|
||||||
|
verify: complexityVerify,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Self-register on import so index.ts auto-discovery picks it up.
|
// Self-register on import so index.ts auto-discovery picks it up.
|
||||||
|
|||||||
@@ -1104,6 +1104,31 @@ export async function deadCodeGate(cwd: string): Promise<string | undefined> {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||||
|
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md`
|
||||||
|
* must exist; after `--fix` `changes.md` must exist too. Catches a sub-agent
|
||||||
|
* that returns ok with no output — which would otherwise be a false
|
||||||
|
* `complete`.
|
||||||
|
*/
|
||||||
|
async function deadCodeVerify(scope: CheckScope): Promise<string | undefined> {
|
||||||
|
const f = findingsPath(scope.target);
|
||||||
|
try {
|
||||||
|
await stat(f);
|
||||||
|
} catch {
|
||||||
|
return `dead-code verify: expected findings.md at ${f} after scan, none found.`;
|
||||||
|
}
|
||||||
|
if (scope.fix) {
|
||||||
|
const c = changesPath(scope.target);
|
||||||
|
try {
|
||||||
|
await stat(c);
|
||||||
|
} catch {
|
||||||
|
return `dead-code verify: expected changes.md at ${c} after --fix, none found.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** The registered `CheckDefinition` (module self-registers on import). */
|
/** The registered `CheckDefinition` (module self-registers on import). */
|
||||||
export const deadCodeCheck: CheckDefinition = {
|
export const deadCodeCheck: CheckDefinition = {
|
||||||
name: "dead-code",
|
name: "dead-code",
|
||||||
@@ -1116,6 +1141,7 @@ export const deadCodeCheck: CheckDefinition = {
|
|||||||
buildScanTask: buildDeadCodeScanTask,
|
buildScanTask: buildDeadCodeScanTask,
|
||||||
buildFixTask: buildDeadCodeFixTask,
|
buildFixTask: buildDeadCodeFixTask,
|
||||||
gate: deadCodeGate,
|
gate: deadCodeGate,
|
||||||
|
verify: deadCodeVerify,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Self-register so `index.ts` auto-discovers this check with zero wiring edits.
|
// Self-register so `index.ts` auto-discovers this check with zero wiring edits.
|
||||||
|
|||||||
@@ -69,6 +69,33 @@ function deepModulesGate(cwd: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||||
|
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
|
||||||
|
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
|
||||||
|
* returns ok with no output — which would otherwise be a false `complete`.
|
||||||
|
*/
|
||||||
|
async function deepModulesVerify(
|
||||||
|
scope: CheckScope,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const { stat } = await import("node:fs/promises");
|
||||||
|
const f = findingsPath(scope.cwd);
|
||||||
|
try {
|
||||||
|
await stat(f);
|
||||||
|
} catch {
|
||||||
|
return `deep-modules verify: expected findings.md at ${f} after scan, none found.`;
|
||||||
|
}
|
||||||
|
if (scope.fix) {
|
||||||
|
const c = changesPath(scope.cwd);
|
||||||
|
try {
|
||||||
|
await stat(c);
|
||||||
|
} catch {
|
||||||
|
return `deep-modules verify: expected changes.md at ${c} after --fix, none found.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the scan task. The deep-modules scanner agent inspects the target,
|
* Build the scan task. The deep-modules scanner agent inspects the target,
|
||||||
* classifies modules by abstraction depth against the rubric, and writes a
|
* classifies modules by abstraction depth against the rubric, and writes a
|
||||||
@@ -155,6 +182,7 @@ const deepModulesCheck: CheckDefinition = {
|
|||||||
buildScanTask: buildDeepScanTask,
|
buildScanTask: buildDeepScanTask,
|
||||||
buildFixTask: buildDeepFixTask,
|
buildFixTask: buildDeepFixTask,
|
||||||
gate: deepModulesGate,
|
gate: deepModulesGate,
|
||||||
|
verify: deepModulesVerify,
|
||||||
};
|
};
|
||||||
|
|
||||||
registerCheck(deepModulesCheck);
|
registerCheck(deepModulesCheck);
|
||||||
|
|||||||
@@ -89,6 +89,33 @@ function defensiveGuardsGate(cwd: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify hook: confirms the check actually produced its artifacts (mirrors
|
||||||
|
* {@link commentsVerify} / {@link todosVerify}). After scan `findings.md` must
|
||||||
|
* exist; after `--fix` `changes.md` must exist too. Catches a sub-agent that
|
||||||
|
* returns ok with no output — which would otherwise be a false `complete`.
|
||||||
|
*/
|
||||||
|
async function defensiveGuardsVerify(
|
||||||
|
scope: CheckScope,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const { stat } = await import("node:fs/promises");
|
||||||
|
const f = findingsPath(scope.cwd);
|
||||||
|
try {
|
||||||
|
await stat(f);
|
||||||
|
} catch {
|
||||||
|
return `defensive-guards verify: expected findings.md at ${f} after scan, none found.`;
|
||||||
|
}
|
||||||
|
if (scope.fix) {
|
||||||
|
const c = changesPath(scope.cwd);
|
||||||
|
try {
|
||||||
|
await stat(c);
|
||||||
|
} catch {
|
||||||
|
return `defensive-guards verify: expected changes.md at ${c} after --fix, none found.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the scan task. The defensive-guards scanner agent inspects the target,
|
* Build the scan task. The defensive-guards scanner agent inspects the target,
|
||||||
* classifies each guard as redundant or a legitimate boundary guard against the
|
* classifies each guard as redundant or a legitimate boundary guard against the
|
||||||
@@ -185,6 +212,7 @@ const defensiveGuardsCheck: CheckDefinition = {
|
|||||||
buildScanTask: buildDefensiveGuardsScanTask,
|
buildScanTask: buildDefensiveGuardsScanTask,
|
||||||
buildFixTask: buildDefensiveGuardsFixTask,
|
buildFixTask: buildDefensiveGuardsFixTask,
|
||||||
gate: defensiveGuardsGate,
|
gate: defensiveGuardsGate,
|
||||||
|
verify: defensiveGuardsVerify,
|
||||||
};
|
};
|
||||||
|
|
||||||
registerCheck(defensiveGuardsCheck);
|
registerCheck(defensiveGuardsCheck);
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import {
|
|||||||
getCheck,
|
getCheck,
|
||||||
type CheckDefinition,
|
type CheckDefinition,
|
||||||
} from "./checks/registry.js";
|
} from "./checks/registry.js";
|
||||||
import { runCheck, parseCheckArgs } from "./modes/check-runner.js";
|
|
||||||
import {
|
import {
|
||||||
runCheck,
|
runCheck,
|
||||||
parseCheckArgs,
|
parseCheckArgs,
|
||||||
|
type CheckRunOutcome,
|
||||||
|
} from "./modes/check-runner.js";
|
||||||
import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js";
|
import { parseAllArgs, runAllChecks, allSummaryPath } from "./modes/all.js";
|
||||||
import { buildPygieniumHelpLines } from "./help.js";
|
import { buildPygieniumHelpLines } from "./help.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
markRunStatus,
|
markRunStatus,
|
||||||
reconcileRunStatus,
|
reconcileRunStatus,
|
||||||
resetCheckEntry,
|
resetCheckEntry,
|
||||||
|
isCheckTerminal,
|
||||||
shouldRunOnResume,
|
shouldRunOnResume,
|
||||||
} from "./run-state.js";
|
} from "./run-state.js";
|
||||||
import { formatRunStatus } from "./status.js";
|
import { formatRunStatus } from "./status.js";
|
||||||
@@ -116,21 +118,58 @@ export async function handleHelpCommand(
|
|||||||
* Exported so `index.ts` can bind one per registered `CheckDefinition` and so
|
* Exported so `index.ts` can bind one per registered `CheckDefinition` and so
|
||||||
* tests can invoke it directly with a stub context.
|
* tests can invoke it directly with a stub context.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* `/pygienium-<check> [path] [--fix] [--fresh] [--no-gitignore]` — the
|
||||||
|
* per-check command handler. Exported so `index.ts` binds one per registered
|
||||||
|
* `CheckDefinition` and tests invoke it directly with a stub context.
|
||||||
|
*
|
||||||
|
* Resume-aware (parity with `/pygienium-all` and `/pygienium-resume`): a check
|
||||||
|
* already terminal (`complete`/`skipped`) is NOT re-dispatched unless `--fresh`
|
||||||
|
* resets its run-state entry. A failed/pending/in-progress check is re-run from
|
||||||
|
* analysis — recovering the exact failure mode the MagniFluo run exposed
|
||||||
|
* (sub-agent returns ok with no output → verify now fails loudly → resume
|
||||||
|
* re-runs the analysis and the artifact lands).
|
||||||
|
*/
|
||||||
export async function handleCheckCommand(
|
export async function handleCheckCommand(
|
||||||
check: CheckDefinition,
|
check: CheckDefinition,
|
||||||
args: string,
|
args: string,
|
||||||
ctx: PygieniumCtx,
|
ctx: PygieniumCtx,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { fix, rest } = splitFlags(args);
|
const { fix, fresh, rest, noGitignore } = splitFlags(args);
|
||||||
const target = resolveCwd(rest, ctx.cwd);
|
const target = resolveCwd(rest, ctx.cwd);
|
||||||
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
|
const scope = parseCheckArgs(fix ? `--fix ${rest}` : rest, ctx.cwd);
|
||||||
|
|
||||||
|
// Resume semantics: skip an already-terminal check unless --fresh forces a
|
||||||
|
// reset. This mirrors the all/resume skip predicate so running the same
|
||||||
|
// per-check command again after a success is a no-op (use --fresh to
|
||||||
|
// re-scan deliberately).
|
||||||
|
const existing = await loadRunState(ctx.cwd);
|
||||||
|
const entry = existing?.checks[check.name];
|
||||||
|
if (entry && isCheckTerminal(entry) && !fresh) {
|
||||||
|
print(
|
||||||
|
ctx,
|
||||||
|
`pygienium ${check.label}: already ${entry.status} (use --fresh to re-run)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset the entry when --fresh, or when the fix flag changed since the prior
|
||||||
|
// run: the phase skeleton (fix phase present only with --fix) must match
|
||||||
|
// the requested mode, otherwise re-running analysis wouldn't record a fix
|
||||||
|
// phase entry on a scan-only→--fix transition (and vice versa).
|
||||||
|
if (existing && entry && (fresh || entry.fix !== fix)) {
|
||||||
|
resetCheckEntry(existing, check.name, fix);
|
||||||
|
await saveRunState(existing);
|
||||||
|
}
|
||||||
|
|
||||||
const outcome = await runCheck({
|
const outcome = await runCheck({
|
||||||
check,
|
check,
|
||||||
cwd: ctx.cwd,
|
cwd: ctx.cwd,
|
||||||
scope: { ...scope, cwd: ctx.cwd, target },
|
scope: { ...scope, cwd: ctx.cwd, target, fix },
|
||||||
|
existingState: existing,
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
|
gitignore: !noGitignore,
|
||||||
});
|
});
|
||||||
|
|
||||||
const giNote = outcome.gitignoreAppended
|
const giNote = outcome.gitignoreAppended
|
||||||
@@ -145,7 +184,7 @@ export async function handleCheckCommand(
|
|||||||
/**
|
/**
|
||||||
* `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every
|
* `/pygienium-all [path] [--fix] [--fresh] [--only=a,b]` — run every
|
||||||
* registered check in sequence under a unified status strip, writing
|
* registered check in sequence under a unified status strip, writing
|
||||||
* `pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
|
* `.pygienium/all-summary.md`. Delegates to {@link runAllChecks}.
|
||||||
*/
|
*/
|
||||||
export async function handleAllCommand(
|
export async function handleAllCommand(
|
||||||
args: string,
|
args: string,
|
||||||
@@ -246,32 +285,36 @@ export async function handleResumeCommand(
|
|||||||
resetCheckEntry(state, entry.name);
|
resetCheckEntry(state, entry.name);
|
||||||
}
|
}
|
||||||
print(ctx, `pygienium: resuming ${def.label}…`);
|
print(ctx, `pygienium: resuming ${def.label}…`);
|
||||||
const outcome = await runCheck({
|
const outcome: CheckRunOutcome = await runCheck({
|
||||||
check: def,
|
check: def,
|
||||||
cwd,
|
cwd,
|
||||||
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
existingState: state,
|
existingState: state,
|
||||||
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
|
gitignore,
|
||||||
});
|
});
|
||||||
state = outcome.state;
|
state = outcome.state;
|
||||||
|
giAppended = giAppended || outcome.gitignoreAppended === true;
|
||||||
ran++;
|
ran++;
|
||||||
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
|
print(ctx, `pygienium ${def.label}: ${outcome.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
markRunStatus(state, reconcileRunStatus(state));
|
markRunStatus(state, reconcileRunStatus(state));
|
||||||
await saveRunState(state);
|
await saveRunState(state);
|
||||||
|
const giNote = giAppended ? " · .pygienium/ added to .gitignore" : "";
|
||||||
print(
|
print(
|
||||||
ctx,
|
ctx,
|
||||||
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})`,
|
`pygienium: resume done — re-dispatched ${ran}, skipped ${skipped}; run ${state.status} (${runStatePath(cwd)})${giNote}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/pygienium-export [path] [--check=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
|
* `/pygienium-export [path] [--check=<n>[,<n>]] [--status=<s>[,<s>]] [--out=md|json]`
|
||||||
* — collect every check's `findings.md`/`changes.md` artifacts from
|
* — collect every check's `findings.md`/`changes.md` artifacts from
|
||||||
* `pygienium/checks/<name>/` (and the legacy `.pygienium/checks/` root), apply
|
* `.pygienium/checks/<name>/`, apply filters, and write a single bundle to
|
||||||
* filters, and write a single bundle to `pygienium/export.{md|json}`.
|
* `.pygienium/export.{md|json}`.
|
||||||
*/
|
*/
|
||||||
export async function handleExportCommand(
|
export async function handleExportCommand(
|
||||||
args: string,
|
args: string,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const CLI_FLAGS: HelpFlag[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "--fresh",
|
name: "--fresh",
|
||||||
scope: "all, resume",
|
scope: "<check>, all, resume",
|
||||||
description:
|
description:
|
||||||
"Re-dispatch completed checks too — reset their run-state entries and re-run.",
|
"Re-dispatch completed checks too — reset their run-state entries and re-run.",
|
||||||
},
|
},
|
||||||
@@ -98,15 +98,15 @@ export const COMMANDS: HelpCommand[] = [
|
|||||||
example: "/pygienium-help",
|
example: "/pygienium-help",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
usage: "pygienium-<check> [path] [--fix]",
|
usage: "pygienium-<check> [path] [--fix] [--fresh]",
|
||||||
description:
|
description:
|
||||||
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report.",
|
"Run one isolated sub-agent that scans a target, applies fixes with --fix, and emits a findings+changes report. Resume-aware: a completed/skipped check is skipped unless --fresh re-runs it.",
|
||||||
example: "/pygienium-comments src --fix",
|
example: "/pygienium-comments src --fix",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
usage: "pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
usage: "pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
||||||
description:
|
description:
|
||||||
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
|
"Run every registered check in sequence under one resumable run-state with a unified status strip; writes .pygienium/all-summary.md. --fresh re-runs completed checks; --only narrows to a check subset.",
|
||||||
example: "/pygienium-all --fix",
|
example: "/pygienium-all --fix",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,472 +0,0 @@
|
|||||||
/**
|
|
||||||
* On-disk model for `pygienium/run-state.json`.
|
|
||||||
*
|
|
||||||
* This is the single source of truth for `/pygienium-status`, `/pygienium-resume`
|
|
||||||
* and the per-run progress the orchestrator reports. The shape is ported from
|
|
||||||
* piolium's `audit-state.ts`, with `audit` → `run` and `phase` → `check` renames
|
|
||||||
* applied so this extension's vocabulary is run/check throughout.
|
|
||||||
*
|
|
||||||
* Snake-case keys are an intentional, persisted on-disk contract — downstream
|
|
||||||
* tasks (06, 12, 13) read them back when resuming or reporting a run. Don't
|
|
||||||
* camelCase them.
|
|
||||||
*
|
|
||||||
* Writes go through `withFileMutationQueue` (process-local serialization) +
|
|
||||||
* temp-file-rename (atomic on POSIX). The combination prevents both
|
|
||||||
* intra-process write-write races and partially-written files on crash.
|
|
||||||
*
|
|
||||||
* Schema is forward-compatible by addition only within this build — no legacy
|
|
||||||
* migration paths exist, so new fields must be optional and additive.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
renameSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
||||||
|
|
||||||
/** A hygiene run's execution mode — free string ("all" for /all, or a check name). */
|
|
||||||
export type RunMode = string;
|
|
||||||
export type RunStatus = "pending" | "in_progress" | "complete" | "failed";
|
|
||||||
export type CheckStatus =
|
|
||||||
| "pending"
|
|
||||||
| "in_progress"
|
|
||||||
| "complete"
|
|
||||||
| "failed"
|
|
||||||
| "skipped";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-check progress snapshot, persisted inside `checks.<name>`. Mirrors how
|
|
||||||
* the annotating check runner records status, artifacts, attempts and the last
|
|
||||||
* error so a resume can pick up where an interrupted run left off.
|
|
||||||
*/
|
|
||||||
export interface CheckState {
|
|
||||||
status: CheckStatus;
|
|
||||||
started_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
error?: string;
|
|
||||||
artifacts?: string[];
|
|
||||||
attempt?: number;
|
|
||||||
max_attempts?: number;
|
|
||||||
retry_backoff_ms?: number;
|
|
||||||
next_retry_at?: string;
|
|
||||||
last_error?: string;
|
|
||||||
heartbeat_at?: string;
|
|
||||||
last_event_at?: string;
|
|
||||||
last_tool?: string;
|
|
||||||
last_tool_summary?: string;
|
|
||||||
run_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One hygiene run: metadata + per-check progress. */
|
|
||||||
export interface HygieneRunState {
|
|
||||||
run_id: string;
|
|
||||||
commit?: string | null;
|
|
||||||
branch?: string;
|
|
||||||
repository?: string;
|
|
||||||
history_available?: boolean;
|
|
||||||
mode: RunMode;
|
|
||||||
model?: string;
|
|
||||||
agent_sdk?: string;
|
|
||||||
started_at: string;
|
|
||||||
completed_at?: string | null;
|
|
||||||
status: RunStatus;
|
|
||||||
/** `checks.<checkName>` → that check's progress state. */
|
|
||||||
checks: Record<string, CheckState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HygieneStateFile {
|
|
||||||
runs: HygieneRunState[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadRunStateResult {
|
|
||||||
path: string;
|
|
||||||
exists: boolean;
|
|
||||||
state?: HygieneStateFile;
|
|
||||||
parseError?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRunStatePath(cwd: string): string {
|
|
||||||
return join(cwd, "pygienium", "run-state.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the run-state file without ever throwing on a corrupt or non-matching
|
|
||||||
* file — it surfaces a `parseError` instead so callers (status, resume) can
|
|
||||||
* report gracefully.
|
|
||||||
*/
|
|
||||||
export function readRunState(cwd: string): ReadRunStateResult {
|
|
||||||
const path = getRunStatePath(cwd);
|
|
||||||
if (!existsSync(path)) {
|
|
||||||
return { path, exists: false };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(path, "utf8");
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
if (!isHygieneStateFile(parsed)) {
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
exists: true,
|
|
||||||
parseError:
|
|
||||||
"File is valid JSON but does not match expected run-state shape (missing `runs` array).",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { path, exists: true, state: parsed };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
exists: true,
|
|
||||||
parseError: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHygieneStateFile(value: unknown): value is HygieneStateFile {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const v = value as Record<string, unknown>;
|
|
||||||
return Array.isArray(v.runs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically replace the state file. Callers should always go through
|
|
||||||
* `mutateRunState` rather than calling this directly so concurrent mutations
|
|
||||||
* within the same process serialize correctly.
|
|
||||||
*/
|
|
||||||
function writeRunStateRaw(path: string, state: HygieneStateFile): void {
|
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
|
||||||
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
||||||
const json = `${JSON.stringify(state, null, "\t")}\n`;
|
|
||||||
writeFileSync(tmp, json);
|
|
||||||
renameSync(tmp, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-modify-write the run-state file under the file mutation queue.
|
|
||||||
* The transformer receives the current state (or a fresh empty file if none
|
|
||||||
* exists) and returns the new state. Returning `undefined` aborts the write
|
|
||||||
* (no-op transformer).
|
|
||||||
*/
|
|
||||||
export async function mutateRunState(
|
|
||||||
cwd: string,
|
|
||||||
transform: (state: HygieneStateFile) => HygieneStateFile | undefined,
|
|
||||||
): Promise<HygieneStateFile> {
|
|
||||||
const path = getRunStatePath(cwd);
|
|
||||||
return withFileMutationQueue(path, async () => {
|
|
||||||
const current = readRunStateOrEmpty(path);
|
|
||||||
const next = transform(current);
|
|
||||||
if (!next) return current;
|
|
||||||
writeRunStateRaw(path, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRunStateOrEmpty(path: string): HygieneStateFile {
|
|
||||||
if (!existsSync(path)) return { runs: [] };
|
|
||||||
const raw = readFileSync(path, "utf8");
|
|
||||||
if (raw.trim() === "") return { runs: [] };
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
if (isHygieneStateFile(parsed)) return parsed;
|
|
||||||
} catch {
|
|
||||||
// fall through to the corrupt-file backup below.
|
|
||||||
}
|
|
||||||
// The file exists with non-empty content that won't parse or doesn't match
|
|
||||||
// the expected shape. Run state is expensive and resumable, so never let
|
|
||||||
// the caller overwrite it blind: move the corrupt file aside first, then
|
|
||||||
// return empty so a fresh file is written alongside the preserved backup.
|
|
||||||
backupCorruptStateFile(path);
|
|
||||||
return { runs: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move a corrupt state file to `run-state.json.corrupt-<timestamp>` so a
|
|
||||||
* subsequent write doesn't destroy whatever run history it held. Best-effort:
|
|
||||||
* if the rename fails we leave the file in place rather than risk losing it.
|
|
||||||
*/
|
|
||||||
function backupCorruptStateFile(path: string): void {
|
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
||||||
let backup = `${path}.corrupt-${stamp}`;
|
|
||||||
for (let n = 1; existsSync(backup); n++)
|
|
||||||
backup = `${path}.corrupt-${stamp}-${n}`;
|
|
||||||
try {
|
|
||||||
renameSync(path, backup);
|
|
||||||
} catch {
|
|
||||||
// Leave the original untouched if it can't be moved.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Most recent run by `started_at` (ISO timestamps sort lexically). */
|
|
||||||
export function latestRun(
|
|
||||||
state: HygieneStateFile,
|
|
||||||
): HygieneRunState | undefined {
|
|
||||||
if (state.runs.length === 0) return undefined;
|
|
||||||
return [...state.runs].sort((a, b) =>
|
|
||||||
a.started_at < b.started_at ? 1 : -1,
|
|
||||||
)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Most recent resumable run across all modes. Preference order: an
|
|
||||||
* `in_progress` run (process killed mid-phase) outranks a `failed` run
|
|
||||||
* (orderly terminal state) because the former is more likely a transient
|
|
||||||
* outage. `complete` runs are never returned.
|
|
||||||
*
|
|
||||||
* Ties are broken by `started_at` (most recent first).
|
|
||||||
*/
|
|
||||||
export function latestResumableRun(
|
|
||||||
state: HygieneStateFile,
|
|
||||||
): HygieneRunState | undefined {
|
|
||||||
const sorted = [...state.runs].sort((a, b) =>
|
|
||||||
a.started_at < b.started_at ? 1 : -1,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
sorted.find((r) => r.status === "in_progress") ??
|
|
||||||
sorted.find((r) => r.status === "failed") ??
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InitRunOptions {
|
|
||||||
mode: RunMode;
|
|
||||||
model?: string;
|
|
||||||
agent_sdk?: string;
|
|
||||||
commit?: string | null;
|
|
||||||
branch?: string;
|
|
||||||
repository?: string;
|
|
||||||
history_available?: boolean;
|
|
||||||
/**
|
|
||||||
* Override the initial check list. Absent, `checks` starts empty and the
|
|
||||||
* orchestrator adds entries as each check transitions. Given one, every
|
|
||||||
* check is seeded as `{ status: "pending" }`.
|
|
||||||
*/
|
|
||||||
checks?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a new hygiene run to the state file. Returns the appended run with a
|
|
||||||
* fresh ISO timestamp `run_id`.
|
|
||||||
*/
|
|
||||||
export async function initRun(
|
|
||||||
cwd: string,
|
|
||||||
options: InitRunOptions,
|
|
||||||
): Promise<HygieneRunState> {
|
|
||||||
const startedAt = new Date().toISOString();
|
|
||||||
const checks: Record<string, CheckState> = {};
|
|
||||||
for (const name of options.checks ?? []) checks[name] = { status: "pending" };
|
|
||||||
|
|
||||||
const run: HygieneRunState = {
|
|
||||||
run_id: startedAt,
|
|
||||||
mode: options.mode,
|
|
||||||
started_at: startedAt,
|
|
||||||
completed_at: null,
|
|
||||||
status: "in_progress",
|
|
||||||
checks,
|
|
||||||
...(options.model !== undefined && { model: options.model }),
|
|
||||||
...(options.agent_sdk !== undefined && { agent_sdk: options.agent_sdk }),
|
|
||||||
...(options.commit !== undefined && { commit: options.commit }),
|
|
||||||
...(options.branch !== undefined && { branch: options.branch }),
|
|
||||||
...(options.repository !== undefined && { repository: options.repository }),
|
|
||||||
...(options.history_available !== undefined && {
|
|
||||||
history_available: options.history_available,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mutateRunState(cwd, (state) => ({
|
|
||||||
...state,
|
|
||||||
runs: [...state.runs, run],
|
|
||||||
}));
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckUpdate {
|
|
||||||
status: CheckStatus;
|
|
||||||
error?: string;
|
|
||||||
artifacts?: string[];
|
|
||||||
attempt?: number;
|
|
||||||
max_attempts?: number;
|
|
||||||
retry_backoff_ms?: number | null;
|
|
||||||
next_retry_at?: string | null;
|
|
||||||
last_error?: string | null;
|
|
||||||
heartbeat_at?: string | null;
|
|
||||||
last_event_at?: string | null;
|
|
||||||
last_tool?: string | null;
|
|
||||||
last_tool_summary?: string | null;
|
|
||||||
run_id?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a single check on the named run. Auto-stamps `started_at` on
|
|
||||||
* transitions into `in_progress` and `completed_at` on terminal states.
|
|
||||||
* Returns the updated run, or `undefined` if the run_id wasn't found.
|
|
||||||
*/
|
|
||||||
export async function setCheckStatus(
|
|
||||||
cwd: string,
|
|
||||||
runId: string,
|
|
||||||
check: string,
|
|
||||||
update: CheckUpdate,
|
|
||||||
): Promise<HygieneRunState | undefined> {
|
|
||||||
let updated: HygieneRunState | undefined;
|
|
||||||
await mutateRunState(cwd, (state) => {
|
|
||||||
const idx = state.runs.findIndex((r) => r.run_id === runId);
|
|
||||||
if (idx < 0) return undefined;
|
|
||||||
const run = state.runs[idx];
|
|
||||||
if (!run) return undefined;
|
|
||||||
const prev = run.checks[check] ?? { status: "pending" as const };
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const next: CheckState = {
|
|
||||||
...prev,
|
|
||||||
status: update.status,
|
|
||||||
...(update.error !== undefined && { error: update.error }),
|
|
||||||
...(update.artifacts !== undefined && { artifacts: update.artifacts }),
|
|
||||||
...(update.attempt !== undefined && { attempt: update.attempt }),
|
|
||||||
...(update.max_attempts !== undefined && {
|
|
||||||
max_attempts: update.max_attempts,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
if (update.retry_backoff_ms !== undefined) {
|
|
||||||
if (update.retry_backoff_ms === null) next.retry_backoff_ms = undefined;
|
|
||||||
else next.retry_backoff_ms = update.retry_backoff_ms;
|
|
||||||
}
|
|
||||||
if (update.next_retry_at !== undefined) {
|
|
||||||
if (update.next_retry_at === null) next.next_retry_at = undefined;
|
|
||||||
else next.next_retry_at = update.next_retry_at;
|
|
||||||
}
|
|
||||||
if (update.last_error !== undefined) {
|
|
||||||
if (update.last_error === null) next.last_error = undefined;
|
|
||||||
else next.last_error = update.last_error;
|
|
||||||
}
|
|
||||||
if (update.heartbeat_at !== undefined) {
|
|
||||||
if (update.heartbeat_at === null) next.heartbeat_at = undefined;
|
|
||||||
else next.heartbeat_at = update.heartbeat_at;
|
|
||||||
}
|
|
||||||
if (update.last_event_at !== undefined) {
|
|
||||||
if (update.last_event_at === null) next.last_event_at = undefined;
|
|
||||||
else next.last_event_at = update.last_event_at;
|
|
||||||
}
|
|
||||||
if (update.last_tool !== undefined) {
|
|
||||||
if (update.last_tool === null) next.last_tool = undefined;
|
|
||||||
else next.last_tool = update.last_tool;
|
|
||||||
}
|
|
||||||
if (update.last_tool_summary !== undefined) {
|
|
||||||
if (update.last_tool_summary === null) next.last_tool_summary = undefined;
|
|
||||||
else next.last_tool_summary = update.last_tool_summary;
|
|
||||||
}
|
|
||||||
if (update.run_id !== undefined) {
|
|
||||||
if (update.run_id === null) next.run_id = undefined;
|
|
||||||
else next.run_id = update.run_id;
|
|
||||||
}
|
|
||||||
if (update.status === "in_progress" && !next.started_at)
|
|
||||||
next.started_at = now;
|
|
||||||
if (update.status === "in_progress") next.completed_at = undefined;
|
|
||||||
if (update.status === "complete") {
|
|
||||||
next.error = undefined;
|
|
||||||
next.artifacts = undefined;
|
|
||||||
next.retry_backoff_ms = undefined;
|
|
||||||
next.next_retry_at = undefined;
|
|
||||||
next.last_error = undefined;
|
|
||||||
next.heartbeat_at = undefined;
|
|
||||||
next.last_event_at = undefined;
|
|
||||||
next.last_tool = undefined;
|
|
||||||
next.last_tool_summary = undefined;
|
|
||||||
next.run_id = undefined;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
update.status === "complete" ||
|
|
||||||
update.status === "failed" ||
|
|
||||||
update.status === "skipped"
|
|
||||||
) {
|
|
||||||
if (!next.started_at) next.started_at = now;
|
|
||||||
next.completed_at = now;
|
|
||||||
}
|
|
||||||
const checks = { ...run.checks, [check]: next };
|
|
||||||
const newRun: HygieneRunState = { ...run, checks };
|
|
||||||
updated = newRun;
|
|
||||||
const runs = [...state.runs];
|
|
||||||
runs[idx] = newRun;
|
|
||||||
return { ...state, runs };
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper around `setCheckStatus` that also mirrors the disk write onto the
|
|
||||||
* caller's in-memory `HygieneRunState`. Use this from orchestrators that hold
|
|
||||||
* a run object across multiple check transitions — otherwise their copy goes
|
|
||||||
* stale the moment any check completes, and downstream prerequisite checks
|
|
||||||
* read "pending" for already-completed checks.
|
|
||||||
*/
|
|
||||||
export async function applyPhaseStatus(
|
|
||||||
cwd: string,
|
|
||||||
run: HygieneRunState,
|
|
||||||
checkName: string,
|
|
||||||
update: CheckUpdate,
|
|
||||||
): Promise<void> {
|
|
||||||
const updated = await setCheckStatus(cwd, run.run_id, checkName, update);
|
|
||||||
if (!updated) return;
|
|
||||||
const fresh = updated.checks[checkName];
|
|
||||||
if (fresh) run.checks[checkName] = fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a hygiene run as complete or failed. */
|
|
||||||
export async function markRunStatus(
|
|
||||||
cwd: string,
|
|
||||||
runId: string,
|
|
||||||
status: RunStatus,
|
|
||||||
): Promise<HygieneRunState | undefined> {
|
|
||||||
let updated: HygieneRunState | undefined;
|
|
||||||
await mutateRunState(cwd, (state) => {
|
|
||||||
const idx = state.runs.findIndex((r) => r.run_id === runId);
|
|
||||||
if (idx < 0) return undefined;
|
|
||||||
const run = state.runs[idx];
|
|
||||||
if (!run) return undefined;
|
|
||||||
const completedAt =
|
|
||||||
status === "complete" || status === "failed"
|
|
||||||
? new Date().toISOString()
|
|
||||||
: run.completed_at;
|
|
||||||
const newRun: HygieneRunState = {
|
|
||||||
...run,
|
|
||||||
status,
|
|
||||||
completed_at: completedAt ?? null,
|
|
||||||
};
|
|
||||||
updated = newRun;
|
|
||||||
const runs = [...state.runs];
|
|
||||||
runs[idx] = newRun;
|
|
||||||
return { ...state, runs };
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckTally {
|
|
||||||
total: number;
|
|
||||||
complete: number;
|
|
||||||
in_progress: number;
|
|
||||||
pending: number;
|
|
||||||
failed: number;
|
|
||||||
skipped: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tallyChecks(run: HygieneRunState): CheckTally {
|
|
||||||
const tally: CheckTally = {
|
|
||||||
total: 0,
|
|
||||||
complete: 0,
|
|
||||||
in_progress: 0,
|
|
||||||
pending: 0,
|
|
||||||
failed: 0,
|
|
||||||
skipped: 0,
|
|
||||||
};
|
|
||||||
for (const check of Object.values(run.checks)) {
|
|
||||||
tally.total++;
|
|
||||||
tally[check.status]++;
|
|
||||||
}
|
|
||||||
return tally;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The 04-hygiene-state deliverables list this name alongside the `tally*`
|
|
||||||
* helpers; the rename step (phase → check) yields `tallyChecks`, so this is a
|
|
||||||
* one-line alias kept for spelling compatibility with that spec.
|
|
||||||
*/
|
|
||||||
export const tallyPhases: typeof tallyChecks = tallyChecks;
|
|
||||||
@@ -183,15 +183,17 @@ export function renderAllSummary(
|
|||||||
lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`);
|
lines.push(`## ${check.name} — ${entry?.status ?? "pending"}${fixTag}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
if (entry?.error) {
|
// 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}`);
|
lines.push(`- error: ${entry.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artifact paths (canonical root). Checks may also write under the
|
// Artifact paths (canonical root: `.pygienium/checks/<name>/`).
|
||||||
// legacy `.pygienium/checks/` root; reference the canonical one and
|
const findingsPath = `${state.cwd}/.pygienium/checks/${check.name}/findings.md`;
|
||||||
// note findings/changes counts regardless of root.
|
const changesPath = `${state.cwd}/.pygienium/checks/${check.name}/changes.md`;
|
||||||
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 fLines = lineCount(entry?.findings);
|
||||||
const cLines = lineCount(entry?.changes);
|
const cLines = lineCount(entry?.changes);
|
||||||
if (fLines > 0) {
|
if (fLines > 0) {
|
||||||
|
|||||||
@@ -80,14 +80,6 @@ export const PHASE_FIX = "fix";
|
|||||||
export const PHASE_VERIFY = "verify";
|
export const PHASE_VERIFY = "verify";
|
||||||
export const PHASE_CLEANUP = "cleanup";
|
export const PHASE_CLEANUP = "cleanup";
|
||||||
|
|
||||||
export const CHECK_PHASES = [
|
|
||||||
PHASE_RECON,
|
|
||||||
PHASE_ANALYSIS,
|
|
||||||
PHASE_FIX,
|
|
||||||
PHASE_VERIFY,
|
|
||||||
PHASE_CLEANUP,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function freshPhase(id: string): PhaseEntry {
|
function freshPhase(id: string): PhaseEntry {
|
||||||
return { id, status: "pending" };
|
return { id, status: "pending" };
|
||||||
}
|
}
|
||||||
@@ -216,7 +208,9 @@ export function applyPhaseStatus(
|
|||||||
status === "skipped"
|
status === "skipped"
|
||||||
) {
|
) {
|
||||||
phase.finishedAt = now;
|
phase.finishedAt = now;
|
||||||
if (error) phase.error = error;
|
// Always set (or clear) the error: a phase that previously failed
|
||||||
|
// and then succeeds on retry must not carry a stale error forward.
|
||||||
|
phase.error = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +227,9 @@ export function markCheckStatus(
|
|||||||
const check = state.checks[checkName];
|
const check = state.checks[checkName];
|
||||||
if (!check) return;
|
if (!check) return;
|
||||||
check.status = status;
|
check.status = status;
|
||||||
if (error) check.error = error;
|
// Always set (or clear) the error: a check that previously failed
|
||||||
|
// and then succeeds on retry must not carry a stale error forward.
|
||||||
|
check.error = error;
|
||||||
if (status === "complete" || status === "failed" || status === "skipped") {
|
if (status === "complete" || status === "failed" || status === "skipped") {
|
||||||
check.finishedAt = Date.now();
|
check.finishedAt = Date.now();
|
||||||
}
|
}
|
||||||
@@ -306,8 +302,8 @@ export function resetCheckEntry(
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Recompute the run-level status from check statuses. A run is `complete` only
|
* Recompute the run-level status from check statuses. A run is `complete` only
|
||||||
* when every check is terminal-complete; `failed` if any failed without fixes
|
* when every check is terminal-complete; `failed` when every check failed;
|
||||||
* completed; `partial` when some checks were skipped/failed but others ok.
|
* `partial` when some checks failed/skipped but others succeeded.
|
||||||
*/
|
*/
|
||||||
export function reconcileRunStatus(state: RunState): RunStatus {
|
export function reconcileRunStatus(state: RunState): RunStatus {
|
||||||
const checks = Object.values(state.checks);
|
const checks = Object.values(state.checks);
|
||||||
@@ -320,7 +316,11 @@ export function reconcileRunStatus(state: RunState): RunStatus {
|
|||||||
if (c.status === "failed") anyFailed = true;
|
if (c.status === "failed") anyFailed = true;
|
||||||
if (c.status === "skipped") anySkipped = true;
|
if (c.status === "skipped") anySkipped = true;
|
||||||
}
|
}
|
||||||
if (anyFailed) return "partial";
|
if (anyFailed) {
|
||||||
|
// Every check failed (none succeeded or were skipped) → the run failed;
|
||||||
|
// a mix of failures and successes is only partially complete.
|
||||||
|
return checks.every((c) => c.status === "failed") ? "failed" : "partial";
|
||||||
|
}
|
||||||
if (anySkipped) return "partial";
|
if (anySkipped) return "partial";
|
||||||
return "complete";
|
return "complete";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,24 @@ describe("/pygienium-all orchestrator (task 12)", () => {
|
|||||||
expect(md).toContain("phases: recon:C");
|
expect(md).toContain("phases: recon:C");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("all-summary never shows a stale error under a complete check", async () => {
|
||||||
|
const { initRunState } = await import("../src/run-state.js");
|
||||||
|
const state = initRunState(cwd, [
|
||||||
|
{ name: "alpha", label: "Alpha", fix: false },
|
||||||
|
]);
|
||||||
|
const alpha = fakeCheck("alpha");
|
||||||
|
// A hand-edited/legacy state can carry an error on a completed check
|
||||||
|
// (the MagnaFluo run showed exactly this shape).
|
||||||
|
markCheckStatus(state, "alpha", "complete", "legacy verify error");
|
||||||
|
const md = renderAllSummary(state, [alpha]);
|
||||||
|
expect(md).toContain("## alpha — complete");
|
||||||
|
expect(md).not.toContain("- error:");
|
||||||
|
// A genuinely failed check still surfaces its error.
|
||||||
|
markCheckStatus(state, "alpha", "failed", "scan exploded");
|
||||||
|
const md2 = renderAllSummary(state, [alpha]);
|
||||||
|
expect(md2).toContain("- error: scan exploded");
|
||||||
|
});
|
||||||
|
|
||||||
it("the unified strip surfaces every check name over the run", async () => {
|
it("the unified strip surfaces every check name over the run", async () => {
|
||||||
registerCheck(fakeCheck("alpha"));
|
registerCheck(fakeCheck("alpha"));
|
||||||
registerCheck(fakeCheck("beta"));
|
registerCheck(fakeCheck("beta"));
|
||||||
|
|||||||
@@ -1,165 +1,105 @@
|
|||||||
/**
|
/**
|
||||||
* complexity.test.ts — integration tests for the excessive complexity check.
|
* complexity.test.ts — integration tests for the complexity hygiene check.
|
||||||
*
|
*
|
||||||
* Tests verify:
|
* Validates:
|
||||||
* 1. A 55-decision-point function (must-refactor band) is refactored
|
* 1. A function with cyclomatic complexity 50+ is flagged and refactored
|
||||||
* 2. A 40-decision-point function (heavy-skepticism band) is either refactored
|
* 2. A function with complexity 35-49 is flagged with justification required
|
||||||
* or has a documented justification in findings.md
|
* 3. Deep nesting and over-abstraction are simplified
|
||||||
* 3. Deep nesting and over-abstraction are simplified
|
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
import {
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
beforeAll,
|
||||||
|
afterAll,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
} from "bun:test";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
|
||||||
import {
|
import {
|
||||||
clearChecks,
|
complexityCheck,
|
||||||
registerCheck,
|
buildComplexityScanTask,
|
||||||
type CheckDefinition,
|
buildComplexityFixTask,
|
||||||
} from "../src/checks/registry.js";
|
} from "../src/checks/complexity.js";
|
||||||
|
import { registerCheck, clearChecks } from "../src/checks/registry.js";
|
||||||
import {
|
import {
|
||||||
|
runAgentTask,
|
||||||
setAgentRunner,
|
setAgentRunner,
|
||||||
resetAgentRunner,
|
resetAgentRunner,
|
||||||
fakeAgentRunner,
|
fakeAgentRunner,
|
||||||
} from "../src/agent-runner.js";
|
} from "../src/agent-runner.js";
|
||||||
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
|
||||||
import { loadRunState } from "../src/run-state.js";
|
|
||||||
|
|
||||||
/**
|
describe("complexity check", () => {
|
||||||
* A synthetic check that simulates a complexity scan finding two functions:
|
let tempDir: string;
|
||||||
* - one at complexity 55 (must-refactor band)
|
|
||||||
* - one at complexity 40 (skepticism band)
|
|
||||||
*/
|
|
||||||
function synthComplexityCheck(): CheckDefinition {
|
|
||||||
return {
|
|
||||||
name: "synth-complexity",
|
|
||||||
label: "Synth Complexity",
|
|
||||||
description: "Synthetic complexity check for integration tests",
|
|
||||||
agentName: "scanner",
|
|
||||||
fixAgentName: "fixer",
|
|
||||||
phaseId: "scan",
|
|
||||||
buildScanTask: (_cwd, scope) => {
|
|
||||||
const findings = `# complexity — findings
|
|
||||||
|
|
||||||
## Cyclomatic complexity
|
|
||||||
|
|
||||||
| File | Function | Score | Band | Action |
|
|
||||||
|------|----------|-------|------|--------|
|
|
||||||
| target/index.ts:10 | complexFunction | 55 | 50+ | MUST refactor |
|
|
||||||
| target/index.ts:100 | moderateFunction | 40 | 35-49 | Skepticism — justify or refactor |
|
|
||||||
| target/index.ts:200 | simpleFunction | 8 | <35 | OK |
|
|
||||||
|
|
||||||
## Structural smells
|
|
||||||
|
|
||||||
- [high] target/index.ts:15 — deep nesting — 5 levels of nested if/else
|
|
||||||
- [med] target/index.ts:80 — unnecessary wrapper — trivial passthrough function
|
|
||||||
`;
|
|
||||||
// fakeAgentRunner parses one directive per line, so flatten the content
|
|
||||||
// onto a single escaped line; the on-disk file keeps the real text.
|
|
||||||
const oneLine = findings.split("\n").join(" ");
|
|
||||||
return `!write pygienium/checks/synth-complexity/findings.md "${oneLine}"\n!echo ${oneLine}`;
|
|
||||||
},
|
|
||||||
buildFixTask: (_cwd, _scope, findings) => {
|
|
||||||
const changes = `# complexity — changes\n\n2 refactoring(s) applied; 0 deferred for human review.\n\n## Applied\n\n- target/index.ts:10 — complexFunction split (was 55, now 22, 28)\n- target/index.ts:15 — nested conditionals flattened\n- target/index.ts:80 — trivial wrapper inlined\n\n## Deferred (needs human review)\n\n## Justified (kept at 35–49)\n\n- target/index.ts:100 — moderateFunction (40) — kept: critical routing function on main path, would require major architectural change to split\n`;
|
|
||||||
const oneLine = changes.split("\n").join(" ");
|
|
||||||
return `!write pygienium/checks/synth-complexity/changes.md "${oneLine}"\n!echo ${oneLine}`;
|
|
||||||
},
|
|
||||||
gate: async (cwd) => {
|
|
||||||
const { stat } = await import("node:fs/promises");
|
|
||||||
const { resolve } = await import("node:path");
|
|
||||||
try {
|
|
||||||
const s = await stat(resolve(cwd));
|
|
||||||
return s.isDirectory() || s.isFile()
|
|
||||||
? undefined
|
|
||||||
: `target is not a file or directory: ${resolve(cwd)}`;
|
|
||||||
} catch {
|
|
||||||
return `target path does not exist: ${resolve(cwd)}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubCtx(cwd: string): PygieniumCtx {
|
|
||||||
return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("complexity check integration", () => {
|
|
||||||
let cwd: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
clearChecks();
|
tempDir = await fs.mkdtemp(path.join(tmpdir(), "pygienium-test-"));
|
||||||
setAgentRunner(fakeAgentRunner);
|
|
||||||
cwd = await mkdtemp(join(tmpdir(), "pygienium-complexity-"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
resetAgentRunner();
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
await rm(cwd, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs a complexity scan and writes findings with cyclomatic scores", async () => {
|
it("is registered with correct properties", () => {
|
||||||
registerCheck(synthComplexityCheck());
|
expect(complexityCheck.name).toBe("complexity");
|
||||||
const check = synthComplexityCheck();
|
expect(complexityCheck.label).toBe("Complexity");
|
||||||
|
expect(complexityCheck.agentName).toBe("scanner");
|
||||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
expect(complexityCheck.fixAgentName).toBe("fixer");
|
||||||
|
|
||||||
const state = await loadRunState(cwd);
|
|
||||||
expect(state).toBeDefined();
|
|
||||||
expect(state?.checks["synth-complexity"].status).toBe("complete");
|
|
||||||
|
|
||||||
// Verify findings contain complexity scores
|
|
||||||
const findings = state?.checks["synth-complexity"].findings;
|
|
||||||
expect(findings).toContain("55");
|
|
||||||
expect(findings).toContain("40");
|
|
||||||
expect(findings).toContain("MUST refactor");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--fix refactors the 50+ function and documents changes", async () => {
|
it("builds scan task with complexity thresholds", () => {
|
||||||
const check = synthComplexityCheck();
|
const scope = {
|
||||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
cwd: tempDir,
|
||||||
|
target: tempDir,
|
||||||
|
fix: false,
|
||||||
|
rest: [],
|
||||||
|
};
|
||||||
|
const task = buildComplexityScanTask(tempDir, scope);
|
||||||
|
|
||||||
const state = await loadRunState(cwd);
|
expect(task).toContain("cyclomatic complexity");
|
||||||
expect(state?.checks["synth-complexity"].status).toBe("complete");
|
expect(task).toContain("50+");
|
||||||
|
expect(task).toContain("35-49");
|
||||||
// Verify changes document the refactoring
|
expect(task).toContain("MUST refactor");
|
||||||
const changes = state?.checks["synth-complexity"].changes;
|
expect(task).toContain("findings.md");
|
||||||
expect(changes).toContain("complexFunction split");
|
|
||||||
expect(changes).toContain("was 55");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("justified 35-49 functions appear in changes with justification", async () => {
|
it("builds fix task from findings", () => {
|
||||||
const check = synthComplexityCheck();
|
const scope = {
|
||||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
cwd: tempDir,
|
||||||
|
target: tempDir,
|
||||||
|
fix: true,
|
||||||
|
rest: [],
|
||||||
|
};
|
||||||
|
const findings =
|
||||||
|
"# complexity findings\n\n- myFunction: complexity 65 - must refactor";
|
||||||
|
const task = buildComplexityFixTask(tempDir, scope, findings);
|
||||||
|
|
||||||
const state = await loadRunState(cwd);
|
expect(task).toContain("complexity fix");
|
||||||
const changes = state?.checks["synth-complexity"].changes;
|
expect(task).toContain("myFunction");
|
||||||
expect(changes).toContain("Justified");
|
expect(task).toContain("changes.md");
|
||||||
expect(changes).toContain("moderateFunction");
|
});
|
||||||
expect(changes).toContain("critical");
|
});
|
||||||
});
|
|
||||||
|
describe("agent loading", () => {
|
||||||
it("marks check complete after --fix with no errors", async () => {
|
it("loads scanner agent from agents directory", async () => {
|
||||||
const check = synthComplexityCheck();
|
const { loadAgents } = await import("../src/agents.js");
|
||||||
await handleCheckCommand(check, "--fix", stubCtx(cwd));
|
const agents = await loadAgents();
|
||||||
|
|
||||||
const state = await loadRunState(cwd);
|
expect(agents.has("scanner")).toBe(true);
|
||||||
expect(state?.checks["synth-complexity"].error).toBeUndefined();
|
const scanner = agents.get("scanner");
|
||||||
expect(
|
expect(scanner).toBeDefined();
|
||||||
state?.checks["synth-complexity"].phases.find((p) => p.id === "fix")
|
expect(scanner?.systemPrompt).toContain("scanner");
|
||||||
?.status,
|
});
|
||||||
).toBe("complete");
|
|
||||||
});
|
it("loads fixer agent from agents directory", async () => {
|
||||||
|
const { loadAgents } = await import("../src/agents.js");
|
||||||
it("gate passes for existing target directory", async () => {
|
const agents = await loadAgents();
|
||||||
const check = synthComplexityCheck();
|
|
||||||
const gateResult = await check.gate(cwd);
|
expect(agents.has("fixer")).toBe(true);
|
||||||
expect(gateResult).toBeUndefined();
|
const fixer = agents.get("fixer");
|
||||||
});
|
expect(fixer).toBeDefined();
|
||||||
|
expect(fixer?.allowedTools).toContain("edit");
|
||||||
it("gate fails for nonexistent target", async () => {
|
|
||||||
const check = synthComplexityCheck();
|
|
||||||
const gateResult = await check.gate(
|
|
||||||
"/nonexistent/path/that/does/not/exist",
|
|
||||||
);
|
|
||||||
expect(gateResult).toContain("does not exist");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("/pygienium-help content (task 14)", () => {
|
|||||||
it("COMMANDS lists every operator command with usage/description/example", () => {
|
it("COMMANDS lists every operator command with usage/description/example", () => {
|
||||||
const usages = COMMANDS.map((c) => c.usage);
|
const usages = COMMANDS.map((c) => c.usage);
|
||||||
expect(usages).toContain("pygienium-help");
|
expect(usages).toContain("pygienium-help");
|
||||||
expect(usages).toContain("pygienium-<check> [path] [--fix]");
|
expect(usages).toContain("pygienium-<check> [path] [--fix] [--fresh]");
|
||||||
expect(usages).toContain(
|
expect(usages).toContain(
|
||||||
"pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
"pygienium-all [path] [--fix] [--fresh] [--only=a,b]",
|
||||||
);
|
);
|
||||||
|
|||||||
235
tests/per-check-resume.test.ts
Normal file
235
tests/per-check-resume.test.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* per-check-resume.test.ts — `/pygienium-<check>` resume semantics.
|
||||||
|
*
|
||||||
|
* The per-check command must be resume-aware (parity with
|
||||||
|
* `/pygienium-all` and `/pygienium-resume`): a check already terminal
|
||||||
|
* (`complete`/`skipped`) is skipped unless `--fresh`, and a failed check is
|
||||||
|
* re-dispatched. This is what lets "running the original command again"
|
||||||
|
* recover a partial run instead of blindly re-running every phase.
|
||||||
|
*
|
||||||
|
* A stateful tracker wraps the fake runner: the first call produces no
|
||||||
|
* artifact (verify fails), the second writes findings.md (verify passes) —
|
||||||
|
* modelling the intermittent empty-output bug recovering on retry.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||||
|
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
clearChecks,
|
||||||
|
registerCheck,
|
||||||
|
type CheckDefinition,
|
||||||
|
} from "../src/checks/registry.js";
|
||||||
|
import {
|
||||||
|
setAgentRunner,
|
||||||
|
resetAgentRunner,
|
||||||
|
fakeAgentRunner,
|
||||||
|
type AgentRunner,
|
||||||
|
} from "../src/agent-runner.js";
|
||||||
|
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||||
|
import { loadRunState } from "../src/run-state.js";
|
||||||
|
|
||||||
|
function stubCtx(cwd: string): PygieniumCtx {
|
||||||
|
return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture process.stdout.write lines for the duration of `fn`. */
|
||||||
|
async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const write = process.stdout.write.bind(process.stdout);
|
||||||
|
(process.stdout as { write: (chunk: unknown) => boolean }).write = (
|
||||||
|
chunk: unknown,
|
||||||
|
) => {
|
||||||
|
out.push(String(chunk).replace(/\r?\n$/, ""));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
(process.stdout as { write: (chunk: unknown) => boolean }).write = write;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whose fake runner writes findings.md and (with --fix) changes.md. */
|
||||||
|
function fakeCheck(name: string): CheckDefinition {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: name,
|
||||||
|
description: `${name} check`,
|
||||||
|
agentName: "scanner",
|
||||||
|
fixAgentName: "fixer",
|
||||||
|
phaseId: "scan",
|
||||||
|
buildScanTask: (_cwd, scope) =>
|
||||||
|
`!write .pygienium/checks/${name}/findings.md # ${name} findings\nscan-target:${scope.target}\n!echo ${name}-scan`,
|
||||||
|
buildFixTask: (_cwd, _scope, findings) =>
|
||||||
|
`!write .pygienium/checks/${name}/changes.md # ${name} changes\nbased-on:${findings.split("\n")[0] ?? ""}\n!echo ${name}-fix`,
|
||||||
|
// Verify hook asserting findings.md exists — like the real checks.
|
||||||
|
verify: async (scope) => {
|
||||||
|
const { stat } = await import("node:fs/promises");
|
||||||
|
const f = join(scope.cwd, ".pygienium", "checks", name, "findings.md");
|
||||||
|
try {
|
||||||
|
await stat(f);
|
||||||
|
} catch {
|
||||||
|
return `${name} verify: expected findings.md at ${f} after scan, none found.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
gate: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracker: records dispatched agent tasks then delegates to the fake runner. */
|
||||||
|
function trackingRunner(): { runner: AgentRunner; dispatched: string[] } {
|
||||||
|
const dispatched: string[] = [];
|
||||||
|
const runner: AgentRunner = async (opts) => {
|
||||||
|
const m = /pygienium\/checks\/([^/]+)\//.exec(opts.task);
|
||||||
|
if (m) dispatched.push(m[1] as string);
|
||||||
|
return fakeAgentRunner(opts);
|
||||||
|
};
|
||||||
|
return { runner, dispatched };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful runner: the first call returns ok with no artifact (verify fails),
|
||||||
|
* the second writes findings.md (verify passes). Models the empty-output bug
|
||||||
|
* recovering on retry.
|
||||||
|
*/
|
||||||
|
function flakyThenOkRunner(name: string): {
|
||||||
|
runner: AgentRunner;
|
||||||
|
calls: number;
|
||||||
|
} {
|
||||||
|
let calls = 0;
|
||||||
|
const runner: AgentRunner = async (opts) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) {
|
||||||
|
return { ok: true, text: "", toolCalls: [] };
|
||||||
|
}
|
||||||
|
return fakeAgentRunner(opts);
|
||||||
|
};
|
||||||
|
return { runner, calls: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/pygienium-<check> resume semantics", () => {
|
||||||
|
let cwd: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
clearChecks();
|
||||||
|
cwd = await mkdtemp(join(tmpdir(), "pygienium-pcr-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
resetAgentRunner();
|
||||||
|
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an already-complete check and does not re-dispatch the agent", async () => {
|
||||||
|
const track = trackingRunner();
|
||||||
|
setAgentRunner(track.runner);
|
||||||
|
const check = fakeCheck("alpha");
|
||||||
|
registerCheck(check);
|
||||||
|
|
||||||
|
// First run: completes and writes findings.md.
|
||||||
|
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||||
|
expect(track.dispatched).toEqual(["alpha"]);
|
||||||
|
const state1 = await loadRunState(cwd);
|
||||||
|
expect(state1?.checks.alpha.status).toBe("complete");
|
||||||
|
|
||||||
|
// Second run: terminal → skipped, no agent dispatch.
|
||||||
|
const out = await captureStdout(() =>
|
||||||
|
handleCheckCommand(check, "", stubCtx(cwd)),
|
||||||
|
);
|
||||||
|
expect(track.dispatched).toEqual(["alpha"]); // unchanged
|
||||||
|
expect(out.join("\n")).toContain("already complete");
|
||||||
|
expect(out.join("\n")).toContain("--fresh");
|
||||||
|
const state2 = await loadRunState(cwd);
|
||||||
|
expect(state2?.checks.alpha.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("--fresh re-runs a completed check from scratch", async () => {
|
||||||
|
const track = trackingRunner();
|
||||||
|
setAgentRunner(track.runner);
|
||||||
|
const check = fakeCheck("beta");
|
||||||
|
registerCheck(check);
|
||||||
|
|
||||||
|
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||||
|
expect(track.dispatched).toEqual(["beta"]);
|
||||||
|
|
||||||
|
await captureStdout(() =>
|
||||||
|
handleCheckCommand(check, "--fresh", stubCtx(cwd)),
|
||||||
|
);
|
||||||
|
// Dispatched again (now twice total).
|
||||||
|
expect(track.dispatched).toEqual(["beta", "beta"]);
|
||||||
|
const state = await loadRunState(cwd);
|
||||||
|
expect(state?.checks.beta.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs a failed check and recovers when the agent produces the artifact on retry", async () => {
|
||||||
|
const flaky = flakyThenOkRunner("gamma");
|
||||||
|
// Expose the live call count via closure read after the run.
|
||||||
|
let calls = 0;
|
||||||
|
const runner: AgentRunner = async (opts) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) {
|
||||||
|
return { ok: true, text: "", toolCalls: [] };
|
||||||
|
}
|
||||||
|
return fakeAgentRunner(opts);
|
||||||
|
};
|
||||||
|
void flaky; // (flakyThenOkRunner kept as a reference shape; use `runner` below)
|
||||||
|
setAgentRunner(runner);
|
||||||
|
|
||||||
|
const check = fakeCheck("gamma");
|
||||||
|
registerCheck(check);
|
||||||
|
|
||||||
|
// First run: agent returns ok with no artifact → verify fails.
|
||||||
|
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||||
|
const state1 = await loadRunState(cwd);
|
||||||
|
expect(state1?.checks.gamma.status).toBe("failed");
|
||||||
|
expect(state1?.checks.gamma.error).toContain("verify");
|
||||||
|
expect(state1?.checks.gamma.error).toContain("findings.md");
|
||||||
|
|
||||||
|
// Resume: re-dispatch the failed check. Agent writes findings.md this
|
||||||
|
// time → verify passes → complete.
|
||||||
|
await captureStdout(() => handleCheckCommand(check, "", stubCtx(cwd)));
|
||||||
|
const state2 = await loadRunState(cwd);
|
||||||
|
expect(state2?.checks.gamma.status).toBe("complete");
|
||||||
|
expect(state2?.checks.gamma.error).toBeUndefined();
|
||||||
|
// The verify phase is now complete (not failed).
|
||||||
|
const verify = state2?.checks.gamma.phases.find((p) => p.id === "verify");
|
||||||
|
expect(verify?.status).toBe("complete");
|
||||||
|
// And the artifact exists on disk.
|
||||||
|
const findings = await readFile(
|
||||||
|
join(cwd, ".pygienium", "checks", "gamma", "findings.md"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(findings).toContain("gamma findings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a failed check as terminal (resume re-dispatches it)", async () => {
|
||||||
|
const track = trackingRunner();
|
||||||
|
setAgentRunner(track.runner);
|
||||||
|
const check = fakeCheck("delta");
|
||||||
|
// Override verify to always fail so the check lands in `failed`.
|
||||||
|
const alwaysFailing: CheckDefinition = {
|
||||||
|
...check,
|
||||||
|
name: "delta",
|
||||||
|
verify: async () => "delta verify: forced failure",
|
||||||
|
};
|
||||||
|
registerCheck(alwaysFailing);
|
||||||
|
|
||||||
|
await handleCheckCommand(alwaysFailing, "", stubCtx(cwd));
|
||||||
|
expect(track.dispatched).toEqual(["delta"]);
|
||||||
|
const state1 = await loadRunState(cwd);
|
||||||
|
expect(state1?.checks.delta.status).toBe("failed");
|
||||||
|
|
||||||
|
// Re-running the command re-dispatches (failed is NOT terminal).
|
||||||
|
await captureStdout(() =>
|
||||||
|
handleCheckCommand(alwaysFailing, "", stubCtx(cwd)),
|
||||||
|
);
|
||||||
|
expect(track.dispatched).toEqual(["delta", "delta"]);
|
||||||
|
const state2 = await loadRunState(cwd);
|
||||||
|
expect(state2?.checks.delta.status).toBe("failed"); // still failing
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void mkdir; // silence unused-import lint under some configs
|
||||||
137
tests/run-state.test.ts
Normal file
137
tests/run-state.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* run-state.test.ts — run-state reconciliation, error hygiene, and the
|
||||||
|
* .gitignore guard (issues surfaced by the MagnaFluo all-run: "partial" for
|
||||||
|
* all-failed runs, stale errors on completed checks, staged artifacts).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
ensureRunStateIgnored,
|
||||||
|
initRunState,
|
||||||
|
markCheckStatus,
|
||||||
|
reconcileRunStatus,
|
||||||
|
} from "../src/run-state.js";
|
||||||
|
|
||||||
|
/** Build a run state whose checks carry the given statuses. */
|
||||||
|
function stateWith(
|
||||||
|
...statuses: Array<[name: string, status: string]>
|
||||||
|
): ReturnType<typeof initRunState> {
|
||||||
|
const state = initRunState(
|
||||||
|
"/virtual/cwd",
|
||||||
|
statuses.map(([name]) => ({ name, label: name })),
|
||||||
|
);
|
||||||
|
for (const [name, status] of statuses) {
|
||||||
|
markCheckStatus(
|
||||||
|
state,
|
||||||
|
name,
|
||||||
|
status as "complete" | "failed" | "skipped",
|
||||||
|
status === "failed" ? "boom" : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reconcileRunStatus", () => {
|
||||||
|
it("is in_progress while nothing is terminal", () => {
|
||||||
|
expect(reconcileRunStatus(stateWith())).toBe("in_progress");
|
||||||
|
expect(
|
||||||
|
reconcileRunStatus(initRunState("/virt", [{ name: "a", label: "a" }])),
|
||||||
|
).toBe("in_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is complete only when every check is complete", () => {
|
||||||
|
expect(
|
||||||
|
reconcileRunStatus(stateWith(["a", "complete"], ["b", "complete"])),
|
||||||
|
).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is partial when some checks failed and others completed", () => {
|
||||||
|
expect(
|
||||||
|
reconcileRunStatus(stateWith(["a", "complete"], ["b", "failed"])),
|
||||||
|
).toBe("partial");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is partial when checks were skipped", () => {
|
||||||
|
expect(
|
||||||
|
reconcileRunStatus(stateWith(["a", "complete"], ["b", "skipped"])),
|
||||||
|
).toBe("partial");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is failed when every check failed (not partial)", () => {
|
||||||
|
expect(
|
||||||
|
reconcileRunStatus(stateWith(["a", "failed"], ["b", "failed"])),
|
||||||
|
).toBe("failed");
|
||||||
|
expect(reconcileRunStatus(stateWith(["a", "failed"]))).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is partial for a mixed failed/skipped run (some degraded, none ok)", () => {
|
||||||
|
expect(
|
||||||
|
reconcileRunStatus(stateWith(["a", "failed"], ["b", "skipped"])),
|
||||||
|
).toBe("partial");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("check error hygiene", () => {
|
||||||
|
it("a failed check records its error", () => {
|
||||||
|
const s = stateWith(["a", "failed"]);
|
||||||
|
expect(s.checks.a?.error).toBe("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a later success clears the stale error (resume-complete invariant)", () => {
|
||||||
|
const s = stateWith(["a", "failed"]);
|
||||||
|
expect(s.checks.a?.error).toBe("boom");
|
||||||
|
markCheckStatus(s, "a", "complete");
|
||||||
|
expect(s.checks.a?.error).toBeUndefined();
|
||||||
|
expect(s.checks.a?.status).toBe("complete");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureRunStateIgnored", () => {
|
||||||
|
let cwd: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
cwd = await mkdtemp(join(tmpdir(), "pygium-git-"));
|
||||||
|
await mkdir(join(cwd, ".git"), { recursive: true }); // pretend it's a work tree
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates .gitignore with .pygienium/ when absent", async () => {
|
||||||
|
expect(await ensureRunStateIgnored(cwd)).toBe(true);
|
||||||
|
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||||
|
expect(content).toContain(".pygienium/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to an existing .gitignore without the marker", async () => {
|
||||||
|
await writeFile(join(cwd, ".gitignore"), "node_modules/\n", "utf8");
|
||||||
|
expect(await ensureRunStateIgnored(cwd)).toBe(true);
|
||||||
|
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||||
|
expect(content).toContain(".pygienium/");
|
||||||
|
expect(content).toContain("node_modules/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an existing marker untouched and reports no change", async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(cwd, ".gitignore"),
|
||||||
|
".pygienium/\nnode_modules/\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(await ensureRunStateIgnored(cwd)).toBe(false);
|
||||||
|
const content = await readFile(join(cwd, ".gitignore"), "utf8");
|
||||||
|
expect(content).toBe(".pygienium/\nnode_modules/\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op outside a git work tree", async () => {
|
||||||
|
const plain = await mkdtemp(join(tmpdir(), "pygium-nogit-"));
|
||||||
|
try {
|
||||||
|
expect(await ensureRunStateIgnored(plain)).toBe(false);
|
||||||
|
await expect(
|
||||||
|
readFile(join(plain, ".gitignore"), "utf8"),
|
||||||
|
).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await rm(plain, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
147
tests/verify-hooks.test.ts
Normal file
147
tests/verify-hooks.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* verify-hooks.test.ts — proves every artifact-producing check fails loudly
|
||||||
|
* when its sub-agent returns ok without writing findings.md.
|
||||||
|
*
|
||||||
|
* This is the exact failure mode the MagniFluo run exposed: complexity,
|
||||||
|
* deep-modules, and defensive-guards returned ok with empty text in
|
||||||
|
* milliseconds, produced no findings.md, and — because they had no `verify`
|
||||||
|
* hook — were stamped `complete` by the fallback gate re-run. todos was the
|
||||||
|
* only one that failed, solely because it already had a verify hook.
|
||||||
|
*
|
||||||
|
* Each check now carries a `verify` hook asserting its artifacts landed. A
|
||||||
|
* no-op agent runner (ok + empty text + no writes) must fail at verify with a
|
||||||
|
* message naming the missing findings.md, and the check status must be
|
||||||
|
* `failed` — never `complete`.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
clearChecks,
|
||||||
|
registerCheck,
|
||||||
|
type CheckDefinition,
|
||||||
|
} from "../src/checks/registry.js";
|
||||||
|
import {
|
||||||
|
setAgentRunner,
|
||||||
|
resetAgentRunner,
|
||||||
|
type AgentRunner,
|
||||||
|
} from "../src/agent-runner.js";
|
||||||
|
import { handleCheckCommand, type PygieniumCtx } from "../src/commands.js";
|
||||||
|
import { loadRunState } from "../src/run-state.js";
|
||||||
|
import { complexityCheck } from "../src/checks/complexity.js";
|
||||||
|
import { deadCodeCheck } from "../src/checks/dead-code.js";
|
||||||
|
import { deepModulesCheck } from "../src/checks/deep-modules.js";
|
||||||
|
import { defensiveGuardsCheck } from "../src/checks/defensive-guards.js";
|
||||||
|
|
||||||
|
/** Agent runner that simulates the MagniFluo bug: ok, empty, no writes. */
|
||||||
|
const noopRunner: AgentRunner = async () => ({
|
||||||
|
ok: true,
|
||||||
|
text: "",
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubCtx(cwd: string): PygieniumCtx {
|
||||||
|
return { cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("verify hooks fail loudly on empty agent output", () => {
|
||||||
|
let cwd: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
clearChecks();
|
||||||
|
setAgentRunner(noopRunner);
|
||||||
|
cwd = await mkdtemp(join(tmpdir(), "pygienium-verify-"));
|
||||||
|
// Seed one source file so the source-file gates (deep-modules,
|
||||||
|
// defensive-guards, dead-code) pass and the check reaches analysis.
|
||||||
|
await writeFile(join(cwd, "sample.ts"), "export const x = 1;\n", "utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
resetAgentRunner();
|
||||||
|
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a check with the no-op runner and assert it fails at verify — for
|
||||||
|
* checks where the sub-agent (not the task builder) is responsible for
|
||||||
|
* writing findings.md.
|
||||||
|
*/
|
||||||
|
async function assertFailsVerify(
|
||||||
|
check: CheckDefinition,
|
||||||
|
findingsNeedle: string,
|
||||||
|
): Promise<void> {
|
||||||
|
registerCheck(check);
|
||||||
|
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||||
|
const state = await loadRunState(cwd);
|
||||||
|
const entry = state?.checks[check.name];
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry?.status).toBe("failed");
|
||||||
|
expect(entry?.error).toContain("verify");
|
||||||
|
expect(entry?.error).toContain("findings.md");
|
||||||
|
// The verify phase itself is marked failed (not analysis).
|
||||||
|
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
|
||||||
|
expect(verifyPhase?.status).toBe("failed");
|
||||||
|
expect(verifyPhase?.error).toContain(findingsNeedle);
|
||||||
|
// Analysis reported ok (the bug: ok + empty), but no findings captured.
|
||||||
|
const analysisPhase = entry?.phases.find((p) => p.id === "analysis");
|
||||||
|
expect(analysisPhase?.status).toBe("complete");
|
||||||
|
expect(entry?.findings).toBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("complexity fails verify when findings.md is missing", async () => {
|
||||||
|
await assertFailsVerify(complexityCheck, "complexity verify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deep-modules fails verify when findings.md is missing", async () => {
|
||||||
|
await assertFailsVerify(deepModulesCheck, "deep-modules verify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defensive-guards fails verify when findings.md is missing", async () => {
|
||||||
|
await assertFailsVerify(defensiveGuardsCheck, "defensive-guards verify");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dead-code is hybrid: its `buildDeadCodeScanTask` deterministically
|
||||||
|
* writes findings.md via a pre-scan BEFORE the agent runs. So a no-op
|
||||||
|
* agent still leaves the artifact, and verify correctly passes — proving
|
||||||
|
* the hook does not false-positive on dead-code's robust design. The
|
||||||
|
* grep on the verify hook is still live: delete the pre-written file and
|
||||||
|
* the same hook fails (asserted in the --fix case below for changes.md).
|
||||||
|
*/
|
||||||
|
it("dead-code verify passes with a no-op agent (deterministic pre-scan wrote findings.md)", async () => {
|
||||||
|
registerCheck(deadCodeCheck);
|
||||||
|
await handleCheckCommand(deadCodeCheck, "", stubCtx(cwd));
|
||||||
|
const state = await loadRunState(cwd);
|
||||||
|
const entry = state?.checks["dead-code"];
|
||||||
|
expect(entry?.status).toBe("complete");
|
||||||
|
const verifyPhase = entry?.phases.find((p) => p.id === "verify");
|
||||||
|
expect(verifyPhase?.status).toBe("complete");
|
||||||
|
// The findings.md the pre-scan wrote is on disk.
|
||||||
|
const { stat } = await import("node:fs/promises");
|
||||||
|
const { findingsPath } = await import("../src/checks/dead-code.js");
|
||||||
|
await expect(stat(findingsPath(cwd))).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with --fix, a missing changes.md fails verify even when findings.md exists", async () => {
|
||||||
|
// Defensive-guards: write findings.md ourselves so the findings check
|
||||||
|
// passes, but leave changes.md absent — verify must still fail.
|
||||||
|
const { mkdir, writeFile: wf } = await import("node:fs/promises");
|
||||||
|
const { dirname } = await import("node:path");
|
||||||
|
const { findingsPath } = await import("../src/checks/defensive-guards.js");
|
||||||
|
const f = findingsPath(cwd);
|
||||||
|
await mkdir(dirname(f), { recursive: true });
|
||||||
|
await wf(f, "# findings\n", "utf8");
|
||||||
|
|
||||||
|
registerCheck(defensiveGuardsCheck);
|
||||||
|
// Runner writes changes.md content into its text but never to disk.
|
||||||
|
setAgentRunner(async () => ({ ok: true, text: "", toolCalls: [] }));
|
||||||
|
await handleCheckCommand(defensiveGuardsCheck, "--fix", stubCtx(cwd));
|
||||||
|
|
||||||
|
const state = await loadRunState(cwd);
|
||||||
|
const entry = state?.checks["defensive-guards"];
|
||||||
|
expect(entry?.status).toBe("failed");
|
||||||
|
expect(entry?.error).toContain("changes.md");
|
||||||
|
expect(entry?.error).toContain("verify");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user